Repository navigation
Show the whole email before Allow once, keep no-sign-in installs to this machine, and refuse cross-site writes - #77
Conversation
…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.
|
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: Fixed here
Remaining findings
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 |
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": 0for 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.exampleshipsOPENBOT_SINGLE_USER=true, so every request is the administrator. The server listened on every interface while its log said127.0.0.1, and the app's dev, preview and installed servers listened on::(every interface) and proxied/apifrom loopback.curl http://<LAN-IP>:PORT/api/meanswered{"role":"admin"}. Nothing checked where a write came from: a page onevil.exampleposted{"behaviour":"allow"}to/api/approvals/rulesastext/plain, got 201, and the Bot then sent email without asking. A WebSocket to/api/channels/eventsopened for anyOrigin.Smaller items in the same area.
POST /api/channels/first-daystored 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_WHILEworked in production.The approach
Three commits, one per area.
Whole email before Allow once.
shareTargetOfnow 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.recipientscounts the distinct addressees. The scope a saved "Always allow" matches is computed exactly as before, so existing rules keep matching; Composio'srecipient_emailand 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.tsdecides 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), plusdocker0on Linux, where Docker Engine mapshost.docker.internalfor the Bots' tool callbacks; Docker Desktop already carries that name to loopback (checked on this Mac). With sign-in nothing changes.OPENBOT_HOST(notHOST, which shells and platforms set for their own reasons) opens either further. Vite andserve.tsfollow the same setting, andstart.shhands it to the app. Both container images setOPENBOT_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.tsis one middleware, registered first increateApp, and the live-screen upgrade inhttp-server.tscalls the same function. For every non-GET/HEAD/OPTIONS request and every WebSocket upgrade it does three things:Originthat is not this deployment's own:OPENBOT_PUBLIC_URL/BETTER_AUTH_URL,OPENBOT_APP_URL, everyTRUSTED_ORIGINSentry, the loopback addresses ofAPP_PORTand the server's port, or the IP address the request came to. A DNS name is never taken fromHost, because it could be a rebinding.Origin, it refuses aSec-Fetch-Sitenaming another site.text/plainor 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
Originand 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'sfetchsends 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/apilet/%61pi/approvals/rulesthrough.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 clientbrief. The first day takesgreeting: "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_WHILEdoes nothing underNODE_ENV=productionand says so at boot.What is not covered
GETanswers. Its writes are refused, because itsOriginis a name that is not configured. Closing this needs aHostallowlist 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.docker0(Podman, Docker Desktop for Linux) is untested for Bot callbacks to a loopback-only server.OPENBOT_HOSTis the remedy and the docs say so.docker run -p 3001:3001with single-user still publishes on every host address. The image cannot know, sodocs/deployment.mdnow 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):null,Sec-Fetch-Siteand text/plain or form POSTs are refused, while the app's own JSON, uploads and body-less writes pass;server/tests/listen-host.test.ts: the loopback default,docker0,OPENBOT_HOSTparsing and refusal, and with sign-in every interface. Real sockets show the loopback default refusing this machine's own LAN address and0.0.0.0accepting it.app/tests/serve.test.tsreads everyBun.servethe installed app makes.app/tests/approval-card-body.test.tsxcovers the fold.server/tests/channel-routes.test.tscovers the server-built brief and greeting, injected text, and the first-day date window.server/tests/openduet-task-turn.test.tscovers the dev flag.Two existing tests changed for the new contract.
plugin-routes.test.tsposted withOrigin: 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:bun test: 9,862 pass, 18 fail, 9 errors. Every failure also fails on untouchedmain:app-access-routes.integration,test-network-guard,tests/agent-bot,residential-proxy,learning-langgraphandlearning-mastra. All 9 errors are packages from sub-projects that the rootbun installdoes 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:
OpenDuet server listening on http://127.0.0.1:3471 and http://[::1]:3471 (this machine only…).curl http://<LAN-IP>:3471/api/meand the app's port on the LAN IP both refused the connection; loopback answered.text/plainrule got 403, the app origin withtext/plaingot 415, andcurl -dgot 415. The app origin with JSON got 201.https://evil.exampleto channel events and the live screen got 403; the app origin opened channel events.9999-12-31started the conversation and leftmorning_brief_onempty./api/approvalswith the whole 410-character body, the subject apart andrecipients: 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.