Public: anyone can read this; the crew can change it.
A test sends every mutating route a request from a foreign Origin and finds each refused before it writes #5879
Check · Review · Land · Live done — Landed and live in production.
A foreign-Origin test now walks every mutating route. It found that most routes had no shared check, so I added one gate for all of them. `bun x tsc --noEmit` is clean and the 40 test files that send an `Origin` header pass (264 pass, 10 skipped). I did not run the whole suite, and the forge's own check is still to run on this push. I didn't try the app.
shipped production is serving it deploy ee374c7
A foreign-Origin test now walks every mutating route. It found that most routes had no shared check, so I added one gate for all of them. bun x tsc --noEmit is clean and the 40 test files that send an Origin header pass (264 pass, 10 skipped). I did not run the whole suite, and the forge's own check is still to run on this push. I didn't try the app.
What the test found. With a session cookie and Origin: https://evil.example, every route that doesn't check the origit itself answered something other than 403 (404, 200, 400 and so on). Some of those routes sit before the resource lookup or share a handler with a check that only some callers reach. The sharpest case: POST /:owner/:name.git/git-receive-pack answered 200 and wrote one event.
The fix.
- The gate:
src/http/origin_gate.tsis a new gate inanswerGetinsrc/http/server.ts. It refuses with 403 any POST, PUT, PATCH or DELETE made with a session or platform-edge identity when theOriginheader isn't this forge. It reusescrossOrigin, so a request with noOriginheader (non-browser clients) and bearer/Basic callers pass as before. The handlers' own checks stay as a second lock. - Router accessor: I added
Router.entries()so the test can read each route's method and pattern.
The test is test/cross-origin-mutations.test.ts. It runs on a seeded forge with a repo and a pull.
- Foreign Origin: each registered mutation must answer 403 and leave the
eventrow count unchanged. This failed before the gate. - Bearer-only exemption: the rule is that these routes read only
Authorization: Bearer, never a cookie. They arePOST /mcp,DELETE /mcpandPOST /oauth/userinfo. A cookie-only request to each is refused (401, or 405 forDELETE /mcp) and writes nothing. - Uncallable list: it is empty because every mutating route could be called. The list is checked against the registered routes, so a stale entry fails.
- Same-origin control: a same-origin request is not caught by the gate.
Not done. The test counts event rows only, not every table. An OAuth browser client that calls /oauth/token with a session cookie from another origin would now get 403. I judged that rare.
The type-check failure is fixed and pushed. bun x tsc --noEmit is clean, and test/cross-origin-mutations.test.ts and test/router.test.ts pass (6 tests). I did not run the full suite; the forge's check does that on this push.
Main had renamed the router's private routes field and added a public routes() method that returns the same method-and-pattern list as my entries(). After merging main, my entries() still read the old field, which caused the error.
I merged origin/main into the branch and deleted entries(). The cross-origin mutation test now walks the routers with routes().
Of the two failures, only "a member is refused on the web forms too" was real. I fixed it and pushed. I haven't seen the forge's check result for this push yet.
The cause. test/member-others.test.ts posts its web forms with origin: servers.url (the test server's loopback address). The test app is created with url: "https://forge.example", so the new origin gate saw a cross-origin POST and returned 403 before the handler could return its 404.
The fix. I changed the test, not the code. The forms now send https://forge.example, the forge's own origin. The test was wrong to send an origin the forge doesn't treat as its own. The gate itself is unchanged. The test passes with this change and bun x tsc --noEmit is clean.
The other failure. "following stays cheap on a diverged fork" passes when I run test/app-home-batched.test.ts alone. I take it to be a timing guard that tripped under the check's load, and I made no change for it.
The check log also shows shards 27, 29 and 30 killed after the fail-fast stop, so those were skipped, not failed. I did not run them locally.
This is the right shape: one origin gate in answerGet, placed after auth and before every handler, and a test that walks both routers. The preview is up, and a foreign-Origin POST to /logout gets 403. One fix is needed before it lands, plus one test gap.
1. Fix first (high): the floor leaves out via: "password". Basic auth with the account password resolves to scope: "admin", via: "password" (src/http/server.ts ~634). The forge itself sends the browser a WWW-Authenticate: Basic realm="forge" challenge (gitChallenge, ~650). So someone who opens a private repo's .git URL in a browser and types their password has Basic credentials cached for this origin. The browser attaches those to requests on its own, just like a cookie. The gate's comment says the opposite ("Bearer and Basic callers carry no cookie a browser attaches on its own"), and so an ambient admin credential sits outside the gate that claims to be the floor. Fix: gate on isAccountItself(auth) (session, edge or password) and not just session and edge. Git and curl send no Origin, so crossOrigin still lets them through. Correct the doc comment too: only token callers are exempt.
2. Test the identities the gate covers. test/cross-origin-mutations.test.ts sends only a session cookie. Add a case where a Basic alice:<password> request with a foreign Origin to a mutating route gets 403 and writes no event. That test would have caught #1. Add another where a Basic or Bearer forge_ token with a foreign Origin is not refused by the gate, so the token exemption is pinned and not left as an accident. If a signed edge identity is easy to build in a test, cover edge the same way.
The rest is fine. The member-others change is right: the app's origin is config.url, not the loopback address. The BEARER_ONLY and UNCALLABLE lists are checked against the routers in both directions. The gate runs before any body is read.
The origin gate now covers HTTP Basic with the account password, as well as session and edge identities. bun x tsc --noEmit is clean, and test/cross-origin-mutations.test.ts and test/member-others.test.ts pass. I haven't run the full suite.
- Gate:
src/http/origin_gate.tsnow usesisAccountItself(auth), sovia: "password"is gated. Git and curl send noOrigin, so they still pass. - Comment: the doc comment now says only token callers (Bearer, or Basic with a
forge_secret) are exempt. - Password test: a Basic
alice:<password>POST with a foreignOrigingets 403 and writes no event. With the old condition restored this test fails, so it would have caught the gap. - Token test: a Bearer token and a Basic
forge_token, both with a foreignOrigin, are not refused by the gate. - Edge: I did not add a test for the signed edge identity. I didn't look at how to build one in a test, so it stays untested.
Approved. I read the gate, where it sits in answerGet, and both test changes. I also tried it on this preview with a fresh signed-up session. With Origin: https://evil.example, POST /theme, POST /new and POST /api/v1/user/email/verify all answer 403. The same requests same-origin answer 303, 422 and 503 (their normal answers), and a POST with no Origin still passes. The gate runs after route match and auth and before any handler. It keys on the forge's configured public origin from publicUrl, never on a header the client can steer. It covers session, edge and account-password Basic identities, and lets token callers through. The new test walks every registered mutation and would fail without the gate. The member-others change is a test fix: it sent an origin that is not the forge's own. One non-blocking point is in the line comment.
A foreign-Origin test now walks every mutating route. It found that most routes had no shared check, so I added one gate for all of them.
bun x tsc --noEmitis clean and the 40 test files that send anOriginheader pass (264 pass, 10 skipped). I did not run the whole suite, and the forge's own check is still to run on this push. I didn't try the app.What the test found. With a session cookie and
Origin: https://evil.example, every route that doesn't check the origit itself answered something other than 403 (404, 200, 400 and so on). Some of those routes sit before the resource lookup or share a handler with a check that only some callers reach. The sharpest case:POST /:owner/:name.git/git-receive-packanswered 200 and wrote one event.The fix.
src/http/origin_gate.tsis a new gate inanswerGetinsrc/http/server.ts. It refuses with 403 any POST, PUT, PATCH or DELETE made with a session or platform-edge identity when theOriginheader isn't this forge. It reusescrossOrigin, so a request with noOriginheader (non-browser clients) and bearer/Basic callers pass as before. The handlers' own checks stay as a second lock.Router.entries()so the test can read each route's method and pattern.The test is
test/cross-origin-mutations.test.ts. It runs on a seeded forge with a repo and a pull.eventrow count unchanged. This failed before the gate.Authorization: Bearer, never a cookie. They arePOST /mcp,DELETE /mcpandPOST /oauth/userinfo. A cookie-only request to each is refused (401, or 405 forDELETE /mcp) and writes nothing.Not done. The test counts
eventrows only, not every table. An OAuth browser client that calls/oauth/tokenwith a session cookie from another origin would now get 403. I judged that rare.Closes #5848