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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5848-a-test-sends-every-mutating-route-a-requ → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/5848-a-test-sends-every-mutating-route-a-requ was merged into main as bd03c4c.

Review

Sign in to review this change.
4 files changed +190 −1
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
outdated

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.

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: via: "password" (HTTP Basic with the account password, admin scope) is a credential the browser caches after the forge's own Basic realm="forge" challenge, and it attaches it to requests on its own. Use if (!isAccountItself(auth)) return undefined; from context.ts. Git clients send no Origin, so they still pass.

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: forbidden() returns "your credentials lack the scope for this action". On the preview that is what a cross-origin refusal says, and it sends whoever is debugging it toward token scopes. A distinct message, e.g. apiError("forbidden", 403, "cross-origin request refused"), would make the cause visible in one look. This matches the log.warn that crossOrigin already writes.

28 }
src/http/server.ts +5
@@ -470,6 +470,7 @@ import { apiRoutes } from "./api.ts";
470470 import { inNamespace } from "../core/proposal_token.ts";
471471 import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts";
472472 import { adminGate } from "./admin_gate.ts";
473 import { originGate } from "./origin_gate.ts";
473474 import { proposalGate } from "./proposal_gate.ts";
474475 import { runGate } from "./run_gate.ts";
475476 import { checksRecheckRoutes } from "./checks_recheck.ts";
@@ -2551,6 +2552,10 @@ async function answerGet(
25512552 noteCaller(request, app.db, auth.user.id, auth.sessionId);
25522553 }
25532554
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
25542559 // A proposal token reaches nothing but what its gate names; every other
25552560 // request it makes ends here, before the limiter or any handler.
25562561 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", () => {
106106 const post = (path: string, form: Record<string, string> = {}) =>
107107 fetch(`${servers.url}${path}`, {
108108 method: "POST",
109 headers: { cookie, origin: servers.url },
109 headers: { cookie, origin: "https://forge.example" },
110110 body: new URLSearchParams(form),
111111 redirect: "manual",
112112 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs