4.4 KiB
Agent Note: One carrier-level browser-trust boundary for all /api routes
Status: implemented
English | 中文
Problem
The web GUI host serves /api over plain loopback HTTP (default 127.0.0.1:3080; the CLI rejects --host 0.0.0.0), and the surface includes remote-code-execution-grade methods — session.prompt drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (text/plain — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the Host header betraying the attacker's domain. Before this decision the system's only browser-trust check (isTrustedNativeDialogRequest: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — host.pickDirectory, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse.
Decision
Enforce browser trust once, at the carrier, for the entire /api prefix — two halves:
- Media-type fence (dsh-host-apiproxy): every
/apiPOST must declareapplication/json, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. - Authority fence (dsh-client-connection,
src/api-request-trust.ts): every request must present aHostthat is loopback or matches atrustedHostsentry (exact onhost:port, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neitherOriginnor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attachedOriginmust equal the Host authority;sec-fetch-site: cross-siteis refused outright. AtrustedHostsentry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant.host.pickDirectoryloses its bespoke guard and rides the same fence.
Reachability is the webserver binding's policy (host: 127.0.0.1 | 0.0.0.0), and this fence is a confused-deputy defense rather than identity. Connection applies the separate browser token authentication after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, trustedHosts names accepted authorities, and the socket address adds nothing the Host/Origin checks need.
Alternatives considered
- Per-RPC guards (status quo extended). Rejected: the guard list trails the method list forever, the highest-value methods were already unguarded, and a loopback rule on browse RPCs would break the remote deployments they exist for.
- CORS headers + credential omission. Rejected: we never want cross-origin reads at all, so answering preflights only widens the surface; refusing them is strictly stronger and simpler.
- Authentication tokens. Rejected for this change: token minting, storage, and rotation are separate product decisions. The later browser token authentication owns them without changing this fence.
Consequences
- Any future
/apimethod is covered by construction; there is no per-route trust decision left to forget. - A custom non-loopback composition must trust its serving authorities or requests are refused, then satisfy browser authentication like every loopback request. The shipped CLI rejects
--host 0.0.0.0;--trusted-hostonly extends the Host/Origin fence and grants no identity. - Clients must label POST bodies
application/json(ours always did; raw-fetch tests gained the header). - Host and Origin remain request-routing evidence only. The process token and signed cookie establish the browser identity used by every Host method.