Skip to content

Refuse a state-changing request another page sent - #274

Merged
adityak74 merged 1 commit into
mainfrom
235-cross-site-guard
Oct 4, 2026
Merged

adityak74 merged 1 commit into
mainfrom
235-cross-site-guard

Conversation

@arjunjaggi34

Copy link
Copy Markdown
Contributor

Refs #235. This is the first slice, the prerequisite named in the design proposal on that issue. It does not close #235.

Problem

The CORS layer (DECISIONS 2026-08-20) stops a foreign page from reading zorp-web's answers. It does not stop a browser from sending a "simple" cross-site request: a POST with a text/plain body or no body at all. The default loopback install has no token, so any handler that takes no JSON body ran for whatever page the person had open. Handlers that take JSON were protected only because axum's Json extractor rejects text/plain, and nothing states that as a rule.

#235 is about to put a dev server, which is model-written JavaScript, in a frame one port away from this page. So this has to exist first.

Change

auth::refuse_cross_site, a middleware on every route, just inside the CORS layer:

  • GET, HEAD and OPTIONS always pass.
  • Any other method passes if one of these holds:
    • Sec-Fetch-Site is same-origin or none
    • the browser sends no Sec-Fetch-Site (older browsers) and Origin matches Host
    • Origin is named with --allow-origin
    • the request has neither header, meaning it is not from a page: curl, the CLI, the Mac app (native URLSession)
  • Everything else gets 403 before any handler runs.

Design choices

  • Why Sec-Fetch-Site rather than Origin vs Host. The compose UI's nginx (web/nginx.conf.template) proxies /api/ with Host rewritten to the upstream, and attaches the bearer token for anyone who reaches it. So in the container split, neither Host nor the token can distinguish the UI from another page. A page cannot forge Sec-Fetch-Site, and the UI's own requests read same-origin through the proxy.
  • Another loopback port is refused. A page on 127.0.0.1:5173 reads same-site, because a site ignores ports. That is exactly the dev-server case Render a running web app in the side pane, not just the files it wrote #235 adds, and it is refused.
  • One rule for every route, rather than patching the specific bodyless handlers. Those are the per-handler accident this replaces.
  • Recorded in a new docs/DECISIONS.md entry (2026-10-03).

Tests: zorp-web/tests/cross_site.rs, 7 tests over real HTTP

Test Shows
a_page_on_another_site_cannot_create_anything POST /api/sessions (bodyless, always 200 when it runs) from a cross-site page gets 403
a_page_on_another_loopback_port_is_refused same-site from 127.0.0.1:5173 gets 403
trusting_an_agent_from_another_page_never_reaches_the_handler 403 instead of the handler's own 404
the_servers_own_page_is_allowed same-origin gets 200, even with a different Origin and Host (the nginx case)
a_request_that_is_not_from_a_page_is_allowed no headers gets 200
without_fetch_metadata_the_origin_must_match_the_host matching Origin gets 200; foreign gets 403; null gets 403
a_named_origin_is_allowed an --allow-origin origin gets 200

Validation

All cargo commands were run one at a time with CARGO_BUILD_JOBS=4, debug info off and incremental off, each under a 30-minute alarm.

  1. cargo test -p zorp-web --test cross_site: 7 passed.
  2. Red check: I removed the middleware from the router and re-ran. 4 failed, the four refusal tests: the trust test got the handler's 404 and the others got 200. The 3 allow tests still passed. I then restored the router.
  3. cargo test -p zorp-web --no-fail-fast: 290 passed, 0 failed. No existing test was affected.
  4. cargo fmt --all -- --check: passed.
  5. cargo clippy -p zorp-web --all-targets --locked -- -D warnings: passed. CI runs this workspace-wide, but this diff touches only zorp-web.

Not run, and why:

  • cargo test --workspace. The change is confined to zorp-web, and the run guidelines for this batch skip the full workspace suite unless the issue calls for it. CI will run it.
  • web/ tests. No TypeScript changed, and the browser sends Sec-Fetch-Site on its own.
  • A live check of the compose stack. Docker is not installed on this machine. The nginx case is covered by the_servers_own_page_is_allowed, which sends a mismatched Host and Origin with same-origin.

Left for #235

Per the proposal on the issue:

  • session-owned background processes (fixing the per-turn orphan)
  • POST /api/preview/check
  • the Preview tab

🤖 Generated with Claude Code

CORS stops a foreign page from reading this server's answers. It does not
stop the browser from sending a simple POST, and the loopback install has
no token, so a handler that takes no JSON body ran for any page the
person visited.

auth::refuse_cross_site runs on every route, inside the CORS layer. A
non-GET request passes when:
- Sec-Fetch-Site is same-origin or none
- for a browser that does not send that header, Origin matches Host
- Origin is named with --allow-origin
- it carries neither header (curl, the CLI, the Mac app)
Anything else gets 403 before a handler runs. Another loopback port reads
same-site and is refused.

This is the first slice of #235: a preview frame runs a dev server's
script one port away from this page, and the guard has to exist first.

Refs #235

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adityak74
adityak74 force-pushed the 235-cross-site-guard branch from 8efeffa to b2a2a5a Compare October 4, 2026 03:04
@adityak74
adityak74 merged commit abae56a into main Oct 4, 2026
11 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.

Render a running web app in the side pane, not just the files it wrote

2 participants