Public: anyone can read this; the crew can change it.
P0: OAuth consent is refused (the consent page's no-referrer makes its own POST Origin: null) — cherry-pick steward/consent-origin #7569
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7567-p0-oauth-consent-is-refused-the-consent → main 1 commit fork
3 files
changed +44
−3
src/http/context.ts +11 −2
| @@ -84,9 +84,18 @@ export function publicUrl(request: Request, base: string | undefined): URL { | ||
| 84 | 84 | */ |
| 85 | 85 | export function crossOrigin(request: Request, url: URL, log?: Log): boolean { |
| 86 | 86 | const origin = request.headers.get("origin"); |
| 87 | const mismatch = origin !== null && origin !== url.origin; | |
| 87 | // `Origin: null` with the browser's own `Sec-Fetch-Site: same-origin` is one of our pages under a strict | |
| 88 | // referrer policy; a page cannot set that header, so it stays a cross-site refusal everywhere else. | |
| 89 | const sameSite = origin === "null" && request.headers.get("sec-fetch-site") === "same-origin"; | |
| 90 | const mismatch = origin !== null && origin !== url.origin && !sameSite; | |
| 88 | 91 | if (mismatch) |
| 89 | log?.warn("refused cross-origin mutation", { origin, expected: url.origin }); | |
| 92 | log?.warn("refused cross-origin mutation", { | |
| 93 | origin, | |
| 94 | expected: url.origin, | |
| 95 | method: request.method, | |
| 96 | path: url.pathname, | |
| 97 | site: request.headers.get("sec-fetch-site") ?? "-", | |
| 98 | }); | |
| 90 | 99 | return mismatch; |
| 91 | 100 | } |
| 92 | 101 |
src/web/pages/oauth.ts +3 −1
| @@ -36,7 +36,9 @@ const PARAMS = [ | ||
| 36 | 36 | /** Nothing here is cacheable, and nothing here may be sent on as a referrer. */ |
| 37 | 37 | const guard = (response: Response): Response => { |
| 38 | 38 | response.headers.set("cache-control", "no-store"); |
| 39 | response.headers.set("referrer-policy", "no-referrer"); | |
| 39 | // same-origin, not no-referrer: no-referrer makes the browser send `Origin: null` on the consent form's | |
| 40 | // own POST, and the CSRF gate refused every consent (2026-10-27). Other sites still get no referrer. | |
| 41 | response.headers.set("referrer-policy", "same-origin"); | |
| 40 | 42 | return response; |
| 41 | 43 | }; |
| 42 | 44 |
test/consent-origin.test.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | /** | |
| 2 | * 2026-10-27: every OAuth consent was refused. The consent page sent `Referrer-Policy: no-referrer`, so the | |
| 3 | * browser posted its own form with `Origin: null`, and the CSRF gate read null as another site. | |
| 4 | */ | |
| 5 | import { expect, test } from "bun:test"; | |
| 6 | import { crossOrigin } from "../src/http/context.ts"; | |
| 7 | ||
| 8 | const url = new URL("https://forge.example/oauth/authorize"); | |
| 9 | const post = (headers: Record<string, string>) => new Request(url.href, { method: "POST", headers: new Headers(headers) }); | |
| 10 | ||
| 11 | test("a same-origin form under a strict referrer policy (Origin: null, Sec-Fetch-Site: same-origin) is ours", () => { | |
| 12 | expect(crossOrigin(post({ origin: "null", "sec-fetch-site": "same-origin" }), url)).toBe(false); | |
| 13 | }); | |
| 14 | ||
| 15 | test("Origin: null from anywhere else is still refused", () => { | |
| 16 | expect(crossOrigin(post({ origin: "null", "sec-fetch-site": "cross-site" }), url)).toBe(true); | |
| 17 | expect(crossOrigin(post({ origin: "null" }), url)).toBe(true); | |
| 18 | }); | |
| 19 | ||
| 20 | test("another site's origin is refused and our own passes", () => { | |
| 21 | expect(crossOrigin(post({ origin: "https://evil.example" }), url)).toBe(true); | |
| 22 | expect(crossOrigin(post({ origin: "https://forge.example" }), url)).toBe(false); | |
| 23 | expect(crossOrigin(post({}), url)).toBe(false); | |
| 24 | }); | |
| 25 | ||
| 26 | test("the consent page asks for a same-origin referrer policy, never no-referrer", async () => { | |
| 27 | const src = await Bun.file(new URL("../src/web/pages/oauth.ts", import.meta.url)).text(); | |
| 28 | expect(src).toContain('"referrer-policy", "same-origin"'); | |
| 29 | expect(src).not.toContain('"referrer-policy", "no-referrer"'); | |
| 30 | }); |
new above
offline