Repository navigation
Refuse cross-site changing requests from a proxy sign-in #715
Description
Part of #701.
Scope
A request signed in through the sign-in proxy that would change something must come from the console's own page. Another website cannot make a signed-in person's browser release a lease, drain a worker or start a command. Requests with a Simlock token are not affected.
In short
A browser can send the proxy's cookie on a request another page starts: a page on a sibling address of the same site, or any site when the cookie is set SameSite=None. So a request whose identity came from the proxy (an "ambient" identity) and changes something must carry Sec-Fetch-Site: same-origin, which the browser itself sets and a page cannot fake. Anything else gets 403.
Browsers send Sec-Fetch-Site only to an https:// address or to loopback. Behind a proxy served over plain http:// on a network name, every changing request signed in through the proxy is refused. The console sends no changing request today, so it loses nothing; a script carrying the proxy's cookie would. The docs say to serve the proxy over HTTPS.
flowchart LR
R[request with an identity] --> A{ambient and not GET/HEAD/OPTIONS?}
A -->|no| OK[continues, as today]
A -->|yes| S{"Sec-Fetch-Site: same-origin? (new)"}
S -->|yes| OK
S -->|no or missing| F[403]
Word: ambient identity, one the browser attaches by itself (the proxy's cookie, turned into a JWT by the proxy), as opposed to a token the caller sends on purpose.
| Request | Today | After |
|---|---|---|
proxy person, GET /v1/leases, any Sec-Fetch-Site |
401 (#714: 200) | 200 |
proxy person, DELETE /v1/leases/x, same-origin |
401 (#714: proceeds) | proceeds |
proxy person, DELETE /v1/leases/x, cross-site |
401 (#714: proceeds) | 403 |
proxy person, POST /v1/workers/w/drain, same-site |
401 (#714: proceeds) | 403 |
proxy person, POST /v1/lease-requests, none |
401 (#714: proceeds) | 403 |
proxy person, POST …, no Sec-Fetch-Site |
401 (#714: proceeds) | 403 |
proxy person, PUT or PATCH to a mount (/v1/stream/…), cross-site |
as the mount | 403 |
proxy person, POST …, Same-Origin, or same-origin, cross-site |
— | 403 (exact value only) |
agent token, DELETE /v1/leases/x, cross-site |
proceeds | proceeds |
The 403 body: { "error": { "code": "FORBIDDEN", "message": "Cross-site request refused: a request signed in through a sign-in proxy must come from the console" } }.
If this goes wrong, another site could make a signed-in person's browser release leases or drain workers.
Technical spec
Facts
Commit ccb50353.
| # | Fact | Source |
|---|---|---|
| F1 | Every /v1 route but /v1/healthz runs agentAuth = requireAuth(...) |
src/http/app.ts:196,227 |
| F2 | State-changing routes: DELETE /v1/components/:platform/:version, POST /v1/devices, DELETE /v1/devices/:id, POST /v1/lease-requests, DELETE /v1/lease-requests/:id, POST /v1/leases/:id/renew, POST /v1/leases/:id/exec, POST /v1/components/install, DELETE /v1/leases/:id, POST /v1/workers/:id/drain, DELETE /v1/workers/:id/drain, DELETE /v1/workers/:id |
rg -n 'app\.(post|delete|put|patch)\(' src/http/app.ts: 12 hits, lines 259-669 |
| F3 | Mounts (/v1/agent-device, /v1/stream) run behind the same auth and take any method (app.all); the other routes take only GET, POST and DELETE, so another method there is 404 before auth |
src/http/app.ts:724-725; src/http/AGENTS.md:39-40 |
| F4 | Identity.ambient is true only for a proxy identity |
#701 Shared values; #714 |
| F5 | The console sends only reads: its API client has get, getJson, stream, healthz |
ui/src/data/api.ts:75-86 |
| F6 | Browsers send Sec-Fetch-* only to a potentially trustworthy URL (https, or loopback) |
W3C Fetch Metadata, §3 "Integration with Fetch" |
Modules touched
Search: F2's, plus rg 'requireAuth' src (one definition, one use).
src/http/auth.ts: the check, after the identity is set, beforenext(). One place, so it covers every route and mount.docs/HTTP-API.md: one paragraph and the 403.
Contract and event changes
none. Reads one more request header, Sec-Fetch-Site, compared to the exact value same-origin; any other value or absence is refused.
Other code on the same state
State: none (a per-request check). Search: rg -i 'sec-fetch|origin' src/http: no hit today. The console sends no changing request (F5).
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
| the check | header same-origin |
continues | none |
| same | any other value, or missing | 403, nothing dispatched | none |
State: none written.
Rules in play
- Architecture rule 10, one place: the check lives once, in
requireAuth, keyed onambient, not on the strategy. - Architecture rule 21:
src/httpreadsambient, never the strategy's name. - Safety rule 10, a claim on the wire: unknown or missing fails closed.
- ADR 0033 §5.
Tests
Seam: createHttpApp with a fake Auth whose identity is ambient or not; model on src/http/app.test.ts.
src/http/app.test.ts: with an ambient identity,POST /v1/workers/w/drainandDELETE /v1/leases/xwithSec-Fetch-Sitecross-site,same-site,none,Same-Origin,same-origin, cross-siteand missing are 403 with the exact body and nothing is dispatched; withsame-originthey are dispatched.src/http/app.test.ts: with an ambient identity,PUTandPATCHto a test mount withcross-siteare 403 and the mount is not called; withsame-originthe mount is called;GET,HEADandOPTIONSto the mount withcross-sitereach the mount.src/http/app.test.ts: with an ambient identity,GET /v1/leaseswithcross-siteis dispatched.src/http/app.test.ts: with a non-ambient (token) identity,DELETE /v1/leases/xwithcross-siteis dispatched.e2e/proxy-auth.test.ts(Sign in through a sign-in proxy's signed JWT #714's file): against a real gateway, a good JWT onPOST /v1/workers/no-such-worker/drainwithcross-siteis 403 with the CSRF message; withsame-originit reaches the dispatcher and answers 404UNKNOWN_WORKER.
Done when
- The tests above pass;
pnpm test:e2e e2e/proxy-auth.test.tspasses. docs/HTTP-API.mdstates the rule, the 403 message, and that a proxy sign-in needs the proxy served over HTTPS (or loopback) for changing requests.
Out of scope
- CORS headers; Origin allow-lists.
- Any rule for token-signed requests.
Depends on
Written by an agent.
Activity
feat: Refuse cross-site changing requests from a proxy sign-in (#715)
docs: Say in the HTTP manual that a proxy sign-in needs Sec-Fetch-Sit…
feat(http): refuse cross-site changing requests from a proxy sign-in (#…
test(real-proxy): run Simlock behind oauth2-proxy and Dex in CI (#740)