Skip to content

Show the whole email before Allow once, keep no-sign-in installs to this machine, and refuse cross-site writes - #77

Merged
jerelvelarde merged 3 commits into
mainfrom
jerel/openduet-fix-security
Oct 26, 2026
Merged

jerelvelarde merged 3 commits into
mainfrom
jerel/openduet-fix-security

Conversation

@jerelvelarde

Copy link
Copy Markdown
Collaborator

The problem

The release review found three ways the app could act for a person without the person seeing or saying so.

An email went out longer than the one that was approved. The private-share check showed the body's first 280 characters, with the subject glued on as the body's last line. The "Send email" card stopped mid-word ("…moves from Friday to S") with nothing to open, Details said "recipients": 0 for an email to one person, and Allow once sent all 414 characters. The inbox also cut every string at 2,000 characters and every list at 20, silently, for every kind of approval.

A default install was the administrator to its whole network and to other sites' pages. .env.example ships OPENBOT_SINGLE_USER=true, so every request is the administrator. The server listened on every interface while its log said 127.0.0.1, and the app's dev, preview and installed servers listened on :: (every interface) and proxied /api from loopback. curl http://<LAN-IP>:PORT/api/me answered {"role":"admin"}. Nothing checked where a write came from: a page on evil.example posted {"behaviour":"allow"} to /api/approvals/rules as text/plain, got 201, and the Bot then sent email without asking. A WebSocket to /api/channels/events opened for any Origin.

Smaller items in the same area. POST /api/channels/first-day stored any shape-valid date as the day's brief, so {"date":"9999-12-31"} blocked every brief until then. The first-day and morning-brief routes ran up to 8,000 characters of the caller's own text after the frame, as a turn in the person's name. OPENDUET_DEV_FAIL_TASKS_WHILE worked in production.

The approach

Three commits, one per area.

Whole email before Allow once. shareTargetOf now describes a connector send in the parts its recipient sees: To, Cc and Bcc as lists, the subject on its own, the whole body, and every other argument of the call (alsoSent: a thread id, an attachment, a format flag). The check stores those as the approval's arguments, so the approval's digest binds all of them: the send after the answer goes out only if it is the same message to the same people, and any change is a new question. recipients counts the distinct addressees. The scope a saved "Always allow" matches is computed exactly as before, so existing rules keep matching; Composio's recipient_email and similar keys are appended to the recipient keys, so a send to them is now scoped to the recipient instead of to the tool's name. The inbox (publicApproval) returns arguments whole, with secrets still masked. The card folds a body over 600 characters or 10 lines, with a visible fade and a "Show all N characters" button; the whole text stays on the page, so a screen reader reads it all. A chat (OpenTag) card, which has room for 300 characters a row, now ends with a row saying it was cut and where to read the rest.

Loopback by default, and same-origin writes. A new shared/listen-host.ts decides where every local listener binds. With no sign-in, the server listens on 127.0.0.1 and ::1 (::1 is optional, for hosts with IPv6 off), plus docker0 on Linux, where Docker Engine maps host.docker.internal for the Bots' tool callbacks; Docker Desktop already carries that name to loopback (checked on this Mac). With sign-in nothing changes. OPENBOT_HOST (not HOST, which shells and platforms set for their own reasons) opens either further. Vite and serve.ts follow the same setting, and start.sh hands it to the app. Both container images set OPENBOT_HOST=0.0.0.0, because a container's loopback is unreachable through its published port. The boot log names the addresses actually bound and warns when a no-sign-in server listens beyond this machine.

auth/same-origin.ts is one middleware, registered first in createApp, and the live-screen upgrade in http-server.ts calls the same function. For every non-GET/HEAD/OPTIONS request and every WebSocket upgrade it does three things:

  • It refuses an Origin that is not this deployment's own: OPENBOT_PUBLIC_URL/BETTER_AUTH_URL, OPENBOT_APP_URL, every TRUSTED_ORIGINS entry, the loopback addresses of APP_PORT and the server's port, or the IP address the request came to. A DNS name is never taken from Host, because it could be a rebinding.
  • With no Origin, it refuses a Sec-Fetch-Site naming another site.
  • It refuses a body sent as text/plain or form-encoded, or untyped from a page (415). Those are the types a page can send cross-site without a preflight that can still carry JSON.

Callers that are not pages send no Origin and pass through to their routes' own authentication. That covers the worker, Bots with their token, the desktop's Rust client, the mobile app with its cookie, and webhooks. Bun's fetch sends a string body with no type, which is why an untyped body is refused only from a page. /api/auth/* is left to Better Auth's own origin check, which also has to accept a SAML IdP's cross-site form post. Signed ingress (/api/events/*, /api/delivery/webhooks/*) keeps the body types Twilio and SNS send. The check covers every path, because Hono decodes a path before routing it: an early version scoped to /api let /%61pi/approvals/rules through.

Server-built first-day and brief text. The two first-day requests and the brief request moved to shared/ and the server builds the framed message itself. The brief ignores any client brief. The first day takes greeting: "with_mail" | "without_mail", and an older app's framed text is read only for which of the two it was. The first-day date is kept only within a day of the server's date, as the brief route already requires. A date further out is ignored, not refused, so a wrong clock still starts the first conversation. OPENDUET_DEV_FAIL_TASKS_WHILE does nothing under NODE_ENV=production and says so at boot.

What is not covered

  • DNS rebinding reads in single-user mode. A page that rebinds its own name to 127.0.0.1 is same-origin to the browser and can read GET answers. Its writes are refused, because its Origin is a name that is not configured. Closing this needs a Host allowlist in single-user mode (loopback names, IP literals, host.docker.internal, configured hosts). I left it out because it can break setups that reach the server by a machine name. It is in the security comment.
  • OpenTag chat approvals still carry only 300 characters a row. The card now says it is cut, but Allow from chat still approves text the chat did not show.
  • "Always allow" scope for a send is still the recipients string cut at 200 characters, kept so saved rules keep matching. Allow once binds the full recipients.
  • GET routes that change state, if any, are outside the guard. I did not audit them.
  • Linux without docker0 (Podman, Docker Desktop for Linux) is untested for Bot callbacks to a loopback-only server. OPENBOT_HOST is the remedy and the docs say so.
  • docker run -p 3001:3001 with single-user still publishes on every host address. The image cannot know, so docs/deployment.md now says to use -p 127.0.0.1:3001:3001.

Verification

New tests:

  • server/tests/approval-whole-preview.test.ts: the whole body, the subject kept apart, recipients counted, the inbox returning bodies over 2,000 characters and 25 recipients whole, Allow once letting out only the email that was shown, and the chat card's cut row.
  • server/tests/plugin-share-target.test.ts: the message parts, the unchanged scope, Composio keys, and quoted display names.
  • server/tests/same-origin.test.ts (22 tests):
    • cross-origin, null, Sec-Fetch-Site and text/plain or form POSTs are refused, while the app's own JSON, uploads and body-less writes pass;
    • non-page callers, signed ingress and the SAML path are untouched;
    • WebSockets for channel events and the live screen;
    • IP-literal and rebinding cases, and encoded paths;
    • through the assembled app, the forged rule from the review gets 403 and is never created, while the app's own and a no-Origin JSON POST get 201.
  • server/tests/listen-host.test.ts: the loopback default, docker0, OPENBOT_HOST parsing and refusal, and with sign-in every interface. Real sockets show the loopback default refusing this machine's own LAN address and 0.0.0.0 accepting it.
  • app/tests/serve.test.ts reads every Bun.serve the installed app makes.
  • app/tests/approval-card-body.test.tsx covers the fold.
  • server/tests/channel-routes.test.ts covers the server-built brief and greeting, injected text, and the first-day date window.
  • server/tests/openduet-task-turn.test.ts covers the dev flag.

Two existing tests changed for the new contract. plugin-routes.test.ts posted with Origin: https://evil.test; that request is now refused before the route, so the test keeps its other attacker-chosen inputs and pins the refusal. The first-day and brief app tests now assert the choice and the text-free body.

Results, on this branch as rebased onto cd4b6229:

  • Targeted suites across the 25 touched files: 626 pass, 0 fail.
  • Full bun test: 9,862 pass, 18 fail, 9 errors. Every failure also fails on untouched main: app-access-routes.integration, test-network-guard, tests/agent-bot, residential-proxy, learning-langgraph and learning-mastra. All 9 errors are packages from sub-projects that the root bun install does not install.
  • bun run typecheck: clean.
  • bun run typecheck:tests: clean against the 239-error baseline.
  • bunx biome lint --error-on-warnings .: clean.
  • bunx biome format .: clean.

End to end, on a real single-user server on port 3471 with Vite on 3472:

  • The boot log said OpenDuet server listening on http://127.0.0.1:3471 and http://[::1]:3471 (this machine only…).
  • curl http://<LAN-IP>:3471/api/me and the app's port on the LAN IP both refused the connection; loopback answered.
  • The review's forged text/plain rule got 403, the app origin with text/plain got 415, and curl -d got 415. The app origin with JSON got 201.
  • WebSockets from https://evil.example to channel events and the live screen got 403; the app origin opened channel events.
  • First day with 9999-12-31 started the conversation and left morning_brief_on empty.
  • A brief posted with injected text queued only the server's own request.
  • A share approval seeded through the real check and store came back from /api/approvals with the whole 410-character body, the subject apart and recipients: 1. In the browser, the 410-character email showed whole, a 925-character one folded behind "Show all 925 characters" and opened in place, and Refuse from the card posted through the guard with 200.

…ts apart

The private-share check put an email to the person as the body's first 280
characters with the subject appended as its last line, and counted
recipients by user id, which an email has none of. So the "Send email" card
stopped mid-word with nothing to open, Details said "recipients": 0 for an
email to one person, and Allow once sent 414 characters of which the person
had seen 280.

A connector send is now described in the parts its recipient sees: To, Cc,
Bcc, the subject on its own, the whole body, and every other argument of the
call. Those are the approval's arguments, so its digest binds all of them: the
send after the answer goes only if it is that same message to those same
people. The inbox returns arguments whole rather than cutting strings at
2,000 characters and lists at 20, and the card folds a long body behind
"Show all N characters" instead of a fixed scrolling box. Composio's
recipient_email and similar keys now name the recipient, so an "always allow"
for one recipient no longer covers every recipient. A chat card, which has
room for 300 characters a row, now says when it is cut and where to read the
rest.
With OPENBOT_SINGLE_USER every request is the administrator, and the server
listened on every interface while its log said 127.0.0.1; the app's dev,
preview and installed servers listened on `::`, which is every interface too,
and proxied /api from loopback. Anybody on the same network got
{"role":"admin"} from /api/me. Separately, there was no check on where a
state-changing request came from: a page on another site posted an "allow"
rule to /api/approvals/rules as text/plain and got 201, and a WebSocket to
/api/channels/events opened for any Origin.

Listening: with no sign-in the server listens on 127.0.0.1 and ::1 (and on
Linux on docker0, where the Bots' containers reach host.docker.internal), and
the app's servers on loopback. OPENBOT_HOST opens either further. The images
set OPENBOT_HOST=0.0.0.0, since a container's own loopback is unreachable
through its published port. With sign-in nothing changes. The boot log now
names the addresses actually bound and warns when a no-sign-in server listens
beyond this machine.

Origins: one middleware, first in createApp, refuses any non-GET/HEAD/OPTIONS
/api request or WebSocket upgrade whose Origin is not one of the deployment's
own (public URL, app URL, TRUSTED_ORIGINS, loopback app and server ports, or
the IP address the request came to), and a browser's Sec-Fetch-Site naming
another site. A body sent as text/plain or a form, or untyped from a page, is
refused with 415, since those are what a page can send cross-site and they can
still carry JSON. Callers that are not pages send no Origin and keep meeting
their routes' own authentication. Better Auth's /api/auth routes, which check
their own origins and take a SAML identity provider's cross-site post, are
left to it, and signed ingress keeps the body types its senders use. The live
screen's upgrade, which is handled before Hono, makes the same check.
…irst-day date

The first-day and morning-brief routes ran up to 8,000 characters of the
caller's own text after the frame, as a turn in the person's name that nobody
typed. Both requests are constants, so the server now writes them: the brief
from its own request, and the first day from the one choice the app makes,
with or without mail. An older app's framed text is read only for which of
the two it was.

The first-day route stored any shape-valid date as the day's brief, so
{"date":"9999-12-31"} turned every brief away until then. It now keeps a date
only within a day of the server's, as the brief route does, and ignores one
further out rather than refusing it, so a wrong clock still gets its first
conversation.

OPENDUET_DEV_FAIL_TASKS_WHILE now does nothing under NODE_ENV=production and
says so at boot, so a line left in a deployed .env cannot fail every task.
@jerelvelarde

Copy link
Copy Markdown
Collaborator Author

Security review of this PR

Scope: the diff, the approval path from a connector send to the inbox and back to the vendor call, the listen addresses, and every client of the state-changing routes (the app, the worker, the desktop's Rust client, mobile, Bots' callbacks, webhooks, SCIM, SAML). I tested the guard against encoded and dot-segment paths. One bypass turned up while writing this PR: /%61pi/approvals/rules reached the route because Hono decodes paths before routing. It is fixed here and pinned in same-origin.test.ts.

Fixed here

  • High. Allow once approved an email the person had seen 280 characters of. The digest now binds the whole message, its subject, its recipients and every other argument, and the card shows all of them.
  • High. The default single-user install answered as administrator on every interface. The server and the app's listeners are now loopback by default, and OPENBOT_HOST opts in to more.
  • High. Cross-site writes and WebSockets were accepted, which let a page switch off Allow once. They are now refused by origin, and JSON-carrying simple content types are refused by type.
  • Low. A far-future first-day date blocked every morning brief. It is now kept only within a day of the server's date, and ignored otherwise.
  • Low. Client text ran as a turn in the person's name. The server now writes the text itself.
  • Low. The dev failure flag worked in production. It is now ignored there.

Remaining findings

  1. Medium: DNS rebinding reads in single-user mode. A page whose name rebinds to 127.0.0.1 is same-origin to the browser, so it can read GET answers: conversations, memory, connection metadata. Its writes are refused, because its Origin is an unconfigured name. Fix: in single-user mode, refuse a Host that is a DNS name other than localhost, host.docker.internal or a configured origin's host. Exempt signed ingress. Not done here, because it breaks anyone reaching a single-user server by machine name without listing it.
  2. Medium: OpenTag chat approvals. These cards carry 300 characters a row. They now say when something is cut, but Allow from chat still approves text the chat did not show. Fix: when a row is cut, offer no Allow in chat and link to the app.
  3. Low: send scope for "Always allow". The scope is still the recipients string cut at 200 characters, kept so saved rules keep matching. A rule saved for a list longer than that covers any list sharing its first 200 characters. Allow once is not affected.
  4. Low: GET routes that change state were not audited and are outside the guard.
  5. Info. /api/auth/* relies on Better Auth's own trusted-origin check, as before.
  6. Info. Secret-looking values in an approval body are still shown as [private value]. The person sees that one is there, not the value. The connector path refuses credential-shaped arguments before it asks.
  7. Info. docker run -p 3001:3001 with single-user publishes on every host address. The docs and the boot warning say so. The image cannot tell.

Limits. This is a code review plus the tests and the end-to-end run in the PR description, not a penetration test. I did not exercise Linux with Docker Engine, Windows, or the desktop app; the docker0 handling is covered by unit tests only. Nothing was tested against a real IdP or a SAML provider.

@jerelvelarde
jerelvelarde merged commit b3cb758 into main Oct 26, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant