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.
scout/forge:scout/5848-a-test-sends-every-mutating-route-a-requ → main 5 commits fork 4 threads · 4 unresolved
src/http/origin_gate.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** | |
| 2 | * The one gate for browser CSRF: a POST, PUT, PATCH or DELETE made with a | |
| 3 | * identity a browser attaches by itself (a session cookie, the platform edge's | |
| 4 | * login, or the account password cached after the forge's Basic challenge) whose | |
| 5 | * `Origin` is not this forge is refused with a 403 before any handler runs, | |
| 6 | * so a route added next year is covered without remembering to check. | |
| 7 | * | |
| 8 | * Only token callers (Bearer, or Basic with a `forge_` secret) are exempt: no | |
| 9 | * browser attaches a token on its own. A request without an `Origin` is no | |
| 10 | * browser's either (git, curl), so it passes, as `crossOrigin` (`context.ts`) | |
| 11 | * has always judged. The handlers' own checks stay as a second | |
| 12 | * lock; this is the floor under them. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { App } from "../app.ts"; | |
| 16 | import type { Auth } from "./context.ts"; | |
| 17 | import { crossOrigin, isAccountItself } from "./context.ts"; | |
| 18 | import { forbidden } from "./respond.ts"; | |
| 19 | ||
| 20 | const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); | |
| 21 | ||
| 22 | /** The refusal for a cross-origin mutation by a cookie identity, or undefined to carry on. */ | |
| 23 | export function originGate(app: App, request: Request, url: URL, auth: Auth): Response | undefined { | |
outdated
High: |
||
| 24 | if (!MUTATING.has(request.method)) return undefined; | |
| 25 | if (!isAccountItself(auth)) return undefined; | |
| 26 | if (!crossOrigin(request, url, app.log)) return undefined; | |
| 27 | return forbidden(); | |
Non-blocking: |
||
| 28 | } |
src/http/server.ts +5
| @@ -470,6 +470,7 @@ import { apiRoutes } from "./api.ts"; | ||
| 470 | 470 | import { inNamespace } from "../core/proposal_token.ts"; |
| 471 | 471 | import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts"; |
| 472 | 472 | import { adminGate } from "./admin_gate.ts"; |
| 473 | import { originGate } from "./origin_gate.ts"; | |
| 473 | 474 | import { proposalGate } from "./proposal_gate.ts"; |
| 474 | 475 | import { runGate } from "./run_gate.ts"; |
| 475 | 476 | import { checksRecheckRoutes } from "./checks_recheck.ts"; |
| @@ -2551,6 +2552,10 @@ async function answerGet( | ||
| 2551 | 2552 | noteCaller(request, app.db, auth.user.id, auth.sessionId); |
| 2552 | 2553 | } |
| 2553 | 2554 | |
| 2555 | // A cookie's identity never acts for another site: one door for every route. | |
| 2556 | const originRefusal = originGate(app, request, url, auth); | |
| 2557 | if (originRefusal !== undefined) return originRefusal; | |
| 2558 | ||
| 2554 | 2559 | // A proposal token reaches nothing but what its gate names; every other |
| 2555 | 2560 | // request it makes ends here, before the limiter or any handler. |
| 2556 | 2561 | const proposalRefusal = proposalGate(app, request, url, auth); |
test/cross-origin-mutations.test.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | /** | |
| 2 | * Same-origin on every mutation, tested as a whole (#5848). Every POST, PUT, | |
| 3 | * PATCH and DELETE either router registers is sent signed in with a session | |
| 4 | * cookie and `Origin: https://evil.example`; each must answer 403 and leave | |
| 5 | * the event log as it was. Routes a bearer token alone opens are exempt by | |
| 6 | * one rule (BEARER_ONLY) and proven to refuse a cookie-only request. Routes | |
| 7 | * the test cannot reach are named in UNCALLABLE with why, and that list is | |
| 8 | * checked against the routers both ways. | |
| 9 | */ | |
| 10 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { openPull } from "../src/actions/pulls.ts"; | |
| 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { createToken } from "../src/core/tokens.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { buildApiRouter, buildRouter, forgeHttp } from "../src/http/server.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | /** | |
| 26 | * The rule: these routes read only `Authorization: Bearer`, never a cookie, so | |
| 27 | * a browser cannot be made to call them as someone. A cookie alone must get a | |
| 28 | * 401 from them, which the second test proves. | |
| 29 | */ | |
| 30 | const BEARER_ONLY = new Set(["POST /mcp", "DELETE /mcp", "POST /oauth/userinfo"]); | |
| 31 | ||
| 32 | /** Routes the test cannot call, with why. Each must be a registered mutation. */ | |
| 33 | const UNCALLABLE: Record<string, string> = {}; | |
| 34 | ||
| 35 | const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); | |
| 36 | const FILL: Record<string, string> = { | |
| 37 | owner: "alice", username: "alice", name: "notes", number: "1", org: "alice", slug: "main", | |
| 38 | id: "1", team: "core", scope: "alice", job: "1", oid: "0", env: "prod", | |
| 39 | }; | |
| 40 | const fill = (pattern: string): string => | |
| 41 | pattern.replace(/:([a-z_]+)/g, (_, k: string) => FILL[k] ?? "x").replace(/\*[a-z_]+/, "x"); | |
| 42 | ||
| 43 | const key = (r: { method: string; pattern: string }): string => `${r.method} ${r.pattern}`; | |
| 44 | const mutations = (): { method: string; pattern: string }[] => | |
| 45 | [...buildRouter().routes(), ...buildApiRouter().routes()].filter((r) => MUTATING.has(r.method)); | |
| 46 | ||
| 47 | let dataDir = "", app: App, cookie = ""; | |
| 48 | const cells = process.env["FORGE_CELLS"]; | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | process.env["FORGE_CELLS"] = "off"; | |
| 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-xo-")); | |
| 53 | app = createApp({ dataDir, port: 0 }); | |
| 54 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 55 | if (!admin.ok) throw new Error(admin.error); | |
| 56 | const repo = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" }); | |
| 57 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 58 | for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) { | |
| 59 | const done = await commitToBranch(app, admin.value, repo.value, { | |
| 60 | ...(branch === undefined ? {} : { branch }), | |
| 61 | message: "change", | |
| 62 | files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }], | |
| 63 | }); | |
| 64 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 65 | } | |
| 66 | const pull = await openPull(app, admin.value, repo.value, { title: "t", body: "", baseBranch: "main", headBranch: "topic" }); | |
| 67 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); | |
| 68 | cookie = `forge_session=${createSession(app.db, admin.value.id).token}`; | |
| 69 | }); | |
| 70 | ||
| 71 | afterAll(async () => { | |
| 72 | app.close(); | |
| 73 | await rm(dataDir, { recursive: true, force: true }); | |
| 74 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 75 | else process.env["FORGE_CELLS"] = cells; | |
| 76 | }); | |
| 77 | ||
| 78 | const events = (): number => (app.db.query("select count(*) as n from event").get() as { n: number }).n; | |
| 79 | ||
outdated
Only a session cookie is sent. Add a Basic-password identity with a foreign Origin (expect 403, no event) and a token identity with a foreign Origin (expect the gate not to refuse), so both sides of the rule are pinned. |
||
| 80 | async function send(r: { method: string; pattern: string }, origin: string | undefined): Promise<number> { | |
| 81 | const headers: Record<string, string> = { cookie, "content-type": "application/json" }; | |
| 82 | if (origin !== undefined) headers["origin"] = origin; | |
| 83 | const res = await forgeHttp(app).handle( | |
| 84 | new Request(`http://forge.test${fill(r.pattern)}`, { method: r.method, headers, body: "{}" }), | |
| 85 | ); | |
| 86 | await res.body?.cancel(); | |
| 87 | return res.status; | |
| 88 | } | |
| 89 | ||
| 90 | test("every mutating route refuses a signed-in request from a foreign Origin, writing nothing", async () => { | |
| 91 | const routes = mutations(); | |
| 92 | expect(routes.length).toBeGreaterThan(100); | |
| 93 | const wrong: string[] = []; | |
| 94 | for (const r of routes) { | |
| 95 | if (BEARER_ONLY.has(key(r)) || key(r) in UNCALLABLE) continue; | |
| 96 | const before = events(); | |
| 97 | const status = await send(r, "https://evil.example"); | |
| 98 | if (status !== 403 || events() !== before) wrong.push(`${key(r)} → ${status}, ${events() - before} events`); | |
| 99 | } | |
| 100 | expect(wrong).toEqual([]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("bearer-only routes carry no cookie: a cookie alone is refused", async () => { | |
| 104 | const registered = new Set(mutations().map(key)); | |
| 105 | for (const route of BEARER_ONLY) { | |
| 106 | expect(registered.has(route)).toBe(true); | |
| 107 | const [method, pattern] = route.split(" ") as [string, string]; | |
| 108 | const before = events(); | |
| 109 | // 401 for no credential; 405 where the route answers nothing but a bearer's POST. | |
| 110 | expect([401, 405]).toContain(await send({ method, pattern }, undefined)); | |
| 111 | expect(events()).toBe(before); | |
| 112 | } | |
| 113 | }); | |
| 114 | ||
| 115 | test("the uncallable list names only registered routes, each with a reason", () => { | |
| 116 | const registered = new Set(mutations().map(key)); | |
| 117 | for (const [route, why] of Object.entries(UNCALLABLE)) { | |
| 118 | expect(registered.has(route)).toBe(true); | |
| 119 | expect(why.length).toBeGreaterThan(0); | |
| 120 | } | |
| 121 | }); | |
| 122 | ||
| 123 | test("a same-origin request is not caught by the gate", async () => { | |
| 124 | const res = await forgeHttp(app).handle( | |
| 125 | new Request("http://forge.test/api/v1/user/email/verify", { | |
| 126 | method: "POST", | |
| 127 | headers: { cookie, origin: "http://forge.test", "content-type": "application/json" }, | |
| 128 | body: "{}", | |
| 129 | }), | |
| 130 | ); | |
| 131 | expect(res.status).not.toBe(403); | |
| 132 | }); | |
| 133 | ||
| 134 | const REPLY = "http://forge.test/api/v1/user/email/verify"; | |
| 135 | const foreign = (authorization: string): Promise<Response> => | |
| 136 | forgeHttp(app).handle( | |
| 137 | new Request(REPLY, { | |
| 138 | method: "POST", | |
| 139 | headers: { authorization, origin: "https://evil.example", "content-type": "application/json" }, | |
| 140 | body: "{}", | |
| 141 | }), | |
| 142 | ); | |
| 143 | ||
| 144 | test("the account password, which a browser caches after the Basic challenge, is gated too", async () => { | |
| 145 | const before = events(); | |
| 146 | const res = await foreign(`Basic ${btoa("alice:correct-horse-battery")}`); | |
| 147 | expect(res.status).toBe(403); | |
| 148 | expect(events()).toBe(before); | |
| 149 | }); | |
| 150 | ||
| 151 | test("token callers are exempt: a foreign Origin is not refused by the gate", async () => { | |
| 152 | const user = app.db.query("select id from user where username = 'alice'").get() as { id: string }; | |
| 153 | const { token } = createToken(app.db, user.id, "xo", "admin"); | |
| 154 | expect((await foreign(`Bearer ${token}`)).status).not.toBe(403); | |
| 155 | expect((await foreign(`Basic ${btoa(`alice:${token}`)}`)).status).not.toBe(403); | |
| 156 | }); |
test/member-others.test.ts +1 −1
| @@ -106,7 +106,7 @@ describe("a member and another account", () => { | ||
| 106 | 106 | const post = (path: string, form: Record<string, string> = {}) => |
| 107 | 107 | fetch(`${servers.url}${path}`, { |
| 108 | 108 | method: "POST", |
| 109 | headers: { cookie, origin: servers.url }, | |
| 109 | headers: { cookie, origin: "https://forge.example" }, | |
| 110 | 110 | body: new URLSearchParams(form), |
| 111 | 111 | redirect: "manual", |
| 112 | 112 | }); |
This isn't true for Basic with a password: browsers cache it and send it automatically. Only token callers (Bearer, or Basic with a
forge_secret) should be named as exempt here.