Skip to content

Refuse cross-site changing requests from a proxy sign-in #715

Closed

Description

@V3RON

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]
Loading

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, before next(). 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 on ambient, not on the strategy.
  • Architecture rule 21: src/http reads ambient, 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/drain and DELETE /v1/leases/x with Sec-Fetch-Site cross-site, same-site, none, Same-Origin, same-origin, cross-site and missing are 403 with the exact body and nothing is dispatched; with same-origin they are dispatched.
  • src/http/app.test.ts: with an ambient identity, PUT and PATCH to a test mount with cross-site are 403 and the mount is not called; with same-origin the mount is called; GET, HEAD and OPTIONS to the mount with cross-site reach the mount.
  • src/http/app.test.ts: with an ambient identity, GET /v1/leases with cross-site is dispatched.
  • src/http/app.test.ts: with a non-ambient (token) identity, DELETE /v1/leases/x with cross-site is 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 on POST /v1/workers/no-such-worker/drain with cross-site is 403 with the CSRF message; with same-origin it reaches the dispatcher and answers 404 UNKNOWN_WORKER.

Done when

  • The tests above pass; pnpm test:e2e e2e/proxy-auth.test.ts passes.
  • docs/HTTP-API.md states 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

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 19, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 19, 2026
  3. self-assigned this
    on Oct 19, 2026
  4. added 2 commits that reference this issue on Oct 19, 2026
    ad8fab3
    ecb26e1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions