Public: anyone can read this; the crew can change it.
Every HTML, JSON and plain-text answer carries Cross-Origin-Resource-Policy: same-origin, and public assets and feeds say cross-origin on purpose #10975
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10965-every-html-json-and-plain-text-answer-ca → main 3 commits fork
5 files
changed +190
−1
docs/SECURITY-RUNBOOK.md +14
| @@ -168,6 +168,20 @@ unset FORGE_TOKEN | ||
| 168 | 168 | |
| 169 | 169 | **One person is locked out:** an admin tells them to use `/recover`, or turns the edge back on (Settings → Capacity) while the person adds a passkey, then runs the dry run again. |
| 170 | 170 | |
| 171 | ## Another site embeds the forge's answers | |
| 172 | ||
| 173 | Every answer the forge writes carries `Cross-Origin-Resource-Policy: same-origin` (set by `securityFloor` in `src/http/respond.ts`). A browser then refuses to load a page, an API answer or an error into another site as an image, script or other no-cors subresource. Without it, another site could pull a signed-in person's private answer into its own process and read it with a Spectre-class attack. | |
| 174 | ||
| 175 | A short public set says `cross-origin` on purpose (`src/http/corp.ts`), because it is meant to be fetched from elsewhere and holds nothing private: | |
| 176 | ||
| 177 | - `/assets/*`, `/icons/*`, `/favicon.ico` and the apple-touch icons: the same bytes for everyone. | |
| 178 | - `/robots.txt`, `/llms.txt`, `/sitemap.xml`, `/manifest.webmanifest` and `/.well-known/*`: read by crawlers, agents and browsers. | |
| 179 | - Feeds (`*.atom`) and share cards (`/og/*`): read by feed readers and link previews. These are `cross-origin` only when the request carried no cookie or token. Asked for with a session, they may hold what only that person can see, so they stay `same-origin`. A cross-site embed sends no session cookie, because the cookie is `SameSite=Lax`. | |
| 180 | ||
| 181 | An error (status 400 or above) on a public path stays `same-origin`. | |
| 182 | ||
| 183 | **A link preview or feed reader breaks:** check the header with `curl -sI https://<forge>/og/site.png | grep -i cross-origin-resource`. It should say `cross-origin`. If a new public path needs it, add it to `src/http/corp.ts` and to `test/corp.test.ts`. | |
| 184 | ||
| 171 | 185 | ## After any of these |
| 172 | 186 | |
| 173 | 187 | Write down what happened, when and what you changed, in an issue on the forge once it's calm. Then check Settings → Security again. Each section shows when it was last checked. |
src/http/corp.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * Cross-Origin-Resource-Policy: which of the forge's answers another site | |
| 3 | * may load as a no-cors subresource (#10965). The security floor makes every | |
| 4 | * answer `same-origin`, so a private page or API answer cannot be pulled into | |
| 5 | * another site's process and read there (a Spectre-class read). The answers | |
| 6 | * here are the exception, said on purpose: they are public by nature and | |
| 7 | * meant to be embedded or fetched from elsewhere — hashed assets, icons, the | |
| 8 | * crawler and agent files, the manifest, `/.well-known/*`, feeds, and the | |
| 9 | * share cards a link preview draws. | |
| 10 | * | |
| 11 | * Feeds and share cards can depend on who asks (a private repository's | |
| 12 | * activity), so they are `cross-origin` only for a request that carried no | |
| 13 | * credentials. A feed reader and a link preview send none; neither does a | |
| 14 | * cross-site embed, since the session cookie is SameSite=Lax. | |
| 15 | */ | |
| 16 | ||
| 17 | /** Paths whose answer is the same bytes for everyone. */ | |
| 18 | const ALWAYS: RegExp[] = [ | |
| 19 | /^\/assets\//, | |
| 20 | /^\/icons\//, | |
| 21 | /^\/\.well-known\//, | |
| 22 | /^\/(?:favicon\.ico|robots\.txt|llms\.txt|sitemap\.xml|manifest\.webmanifest)$/, | |
| 23 | /^\/apple-touch-icon(?:-precomposed)?\.png$/, | |
| 24 | ]; | |
| 25 | ||
| 26 | /** Paths public to an anonymous reader, which may differ for a signed-in one. */ | |
| 27 | const ANONYMOUS: RegExp[] = [/\.atom$/, /^\/og\//]; | |
| 28 | ||
| 29 | /** Whether the answer to this request may be loaded by another origin. */ | |
| 30 | export function crossOriginAllowed(request: Request, pathname: string): boolean { | |
| 31 | if (ALWAYS.some((pattern) => pattern.test(pathname))) return true; | |
| 32 | if (!ANONYMOUS.some((pattern) => pattern.test(pathname))) return false; | |
| 33 | return !request.headers.has("cookie") && !request.headers.has("authorization"); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Marks a successful public answer `cross-origin` before the floor fills in | |
| 38 | * `same-origin` for everything else. A refusal or 404 on a public path stays | |
| 39 | * with the floor: an error is nobody's to embed. | |
| 40 | */ | |
| 41 | export function resourcePolicy(request: Request, response: Response): Response { | |
| 42 | if (response.status >= 400 || response.headers.has("cross-origin-resource-policy")) return response; | |
| 43 | let pathname: string; | |
| 44 | try { | |
| 45 | pathname = new URL(request.url).pathname; | |
| 46 | } catch { | |
| 47 | return response; | |
| 48 | } | |
| 49 | if (!crossOriginAllowed(request, pathname)) return response; | |
| 50 | try { | |
| 51 | response.headers.set("cross-origin-resource-policy", "cross-origin"); | |
| 52 | } catch { | |
| 53 | // An immutable header set keeps what it came with; the floor leaves it too. | |
| 54 | } | |
| 55 | return response; | |
| 56 | } |
src/http/respond.ts +4
| @@ -225,6 +225,10 @@ export function securityFloor(response: Response): Response { | ||
| 225 | 225 | headers.set("strict-transport-security", HSTS); |
| 226 | 226 | if (!headers.has("cross-origin-opener-policy")) |
| 227 | 227 | headers.set("cross-origin-opener-policy", "same-origin"); |
| 228 | // No other site loads this as a subresource unless the answer said so | |
| 229 | // (the public set in corp.ts says `cross-origin` before this runs). | |
| 230 | if (!headers.has("cross-origin-resource-policy")) | |
| 231 | headers.set("cross-origin-resource-policy", "same-origin"); | |
| 228 | 232 | // What left decides, not what was asked for: an HTML body writes the |
| 229 | 233 | // only policy that can name its own nonce, and `default-src 'none'` |
| 230 | 234 | // over a page would strip it of its stylesheet. |
src/http/server.ts +2 −1
| @@ -481,6 +481,7 @@ import type { Database } from "bun:sqlite"; | ||
| 481 | 481 | import { mcpRoutes, REPORT_TOOL } from "./mcp.ts"; |
| 482 | 482 | import { TOOLS } from "./mcp/tools.ts"; |
| 483 | 483 | import { setMcpTools } from "../web/llms_tools.ts"; |
| 484 | import { resourcePolicy } from "./corp.ts"; | |
| 484 | 485 | import { agentsPage } from "../web/pages/agents.ts"; |
| 485 | 486 | import { openapiRoute } from "../contract/served.ts"; |
| 486 | 487 | import { createUsageCounter, type UsageCounter } from "../core/usage_count.ts"; |
| @@ -2500,7 +2501,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2500 | 2501 | if (url.pathname.startsWith("/api/v1/")) |
| 2501 | 2502 | response.headers.set("www-authenticate", bearerChallenge(url.origin, request)); |
| 2502 | 2503 | } |
| 2503 | const done = await compress(request, response).then(securityFloor).then((r) => privateWhenSignedIn(request, r)); | |
| 2504 | const done = await compress(request, response).then((r) => securityFloor(resourcePolicy(request, r))).then((r) => privateWhenSignedIn(request, r)); | |
| 2504 | 2505 | if (namesFrame(request)) notePhase(request, "compress", performance.now() - compressing); |
| 2505 | 2506 | return done; |
| 2506 | 2507 | }, |
test/corp.test.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * Cross-Origin-Resource-Policy (#10965): a page, an API answer and a 404 are | |
| 3 | * `same-origin`, so another site cannot load them as a no-cors subresource; | |
| 4 | * the public set — assets, icons, feeds, the crawler and agent files, the | |
| 5 | * manifest, `/.well-known/*` and the share cards link previews draw — says | |
| 6 | * `cross-origin` on purpose. A feed or share card asked for with a session | |
| 7 | * stays `same-origin`: it may hold what only that person can see. | |
| 8 | */ | |
| 9 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 15 | import { createRepo } from "../src/core/repos.ts"; | |
| 16 | import { createSession } from "../src/core/sessions.ts"; | |
| 17 | import { crossOriginAllowed } from "../src/http/corp.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { spriteAsset, stylesheetAsset } from "../src/web/asset.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | let dataDir = ""; | |
| 26 | let app: App, servers: Servers; | |
| 27 | let cookie = ""; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dataDir = await mkdtemp(join(tmpdir(), "forge-corp-")); | |
| 31 | app = createApp({ dataDir, port: 0 }); | |
| 32 | servers = await serve(app); | |
| 33 | const admin = await seedAdmin(app, { username: "ada", email: "[email protected]", password: "correct-horse-battery" }); | |
| 34 | if (!admin.ok) throw new Error(admin.error); | |
| 35 | const made = await createRepo(app.db, app.reposRoot, { ownerId: admin.value.id, name: "notes", visibility: "public" }); | |
| 36 | if (!made.ok) throw new Error(String(made.error)); | |
| 37 | const seed = await commitToBranch(app, admin.value, made.value, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 38 | if (!seed.ok) throw new Error(String(seed.error)); | |
| 39 | cookie = `forge_session=${createSession(app.db, admin.value.id).token}`; | |
| 40 | }, 30_000); | |
| 41 | ||
| 42 | afterAll(async () => { | |
| 43 | servers?.stop(); | |
| 44 | app?.close(); | |
| 45 | await rm(dataDir, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | async function corp(path: string, signedIn = false): Promise<[number, string | null]> { | |
| 49 | const headers: Record<string, string> = { "accept-encoding": "identity" }; | |
| 50 | if (signedIn) headers.cookie = cookie; | |
| 51 | const res = await fetch(`${servers.url}${path}`, { headers, redirect: "manual", signal: AbortSignal.timeout(10_000) }); | |
| 52 | await res.body?.cancel(); | |
| 53 | return [res.status, res.headers.get("cross-origin-resource-policy")]; | |
| 54 | } | |
| 55 | ||
| 56 | const SAME_ORIGIN: [string, string | (() => string), boolean?][] = [ | |
| 57 | ["page", "/"], | |
| 58 | ["page, signed in", "/", true], | |
| 59 | ["repository page", "/ada/notes"], | |
| 60 | ["API answer", "/api/v1/repos/ada/notes"], | |
| 61 | ["API answer, signed in", "/api/v1/user", true], | |
| 62 | ["404 page", "/no/such/path/at/all"], | |
| 63 | ["404 JSON", "/api/v1/no-such-route"], | |
| 64 | ["404 of an asset", "/assets/not-a-real-hash.css"], | |
| 65 | ["feed, signed in", "/ada/notes/activity.atom", true], | |
| 66 | ["share card, signed in", "/og/site.svg", true], | |
| 67 | ]; | |
| 68 | ||
| 69 | const CROSS_ORIGIN: [string, string | (() => string)][] = [ | |
| 70 | ["stylesheet asset", () => `/assets/${stylesheetAsset().name}.css`], | |
| 71 | ["sprite asset", () => `/assets/${spriteAsset().name}.svg`], | |
| 72 | ["changelog feed", "/changelog.atom"], | |
| 73 | ["repository feed", "/ada/notes/activity.atom"], | |
| 74 | ["robots.txt", "/robots.txt"], | |
| 75 | ["llms.txt", "/llms.txt"], | |
| 76 | ["sitemap", "/sitemap.xml"], | |
| 77 | ["manifest", "/manifest.webmanifest"], | |
| 78 | ["well-known", "/.well-known/security.txt"], | |
| 79 | ["favicon", "/favicon.ico"], | |
| 80 | ["site share card (svg)", "/og/site.svg"], | |
| 81 | ["site share card (png)", "/og/site.png"], | |
| 82 | ["repository share card", "/og/repo/ada~notes.svg"], | |
| 83 | ]; | |
| 84 | ||
| 85 | test("private answers, API answers and errors are same-origin", async () => { | |
| 86 | const wrong: string[] = []; | |
| 87 | for (const [kind, path, signedIn] of SAME_ORIGIN) { | |
| 88 | const [status, value] = await corp(typeof path === "function" ? path() : path, signedIn === true); | |
| 89 | if (value !== "same-origin") wrong.push(`${kind} (${status}): ${value}`); | |
| 90 | } | |
| 91 | expect(wrong).toEqual([]); | |
| 92 | }, 60_000); | |
| 93 | ||
| 94 | test("the public set says cross-origin on purpose", async () => { | |
| 95 | const wrong: string[] = []; | |
| 96 | for (const [kind, path] of CROSS_ORIGIN) { | |
| 97 | const [status, value] = await corp(typeof path === "function" ? path() : path); | |
| 98 | if (status >= 400) wrong.push(`${kind}: answered ${status}`); | |
| 99 | else if (value !== "cross-origin") wrong.push(`${kind} (${status}): ${value}`); | |
| 100 | } | |
| 101 | expect(wrong).toEqual([]); | |
| 102 | }, 60_000); | |
| 103 | ||
| 104 | test("only the public set is exempt", () => { | |
| 105 | const anonymous = new Request("http://forge.local/"); | |
| 106 | const signed = new Request("http://forge.local/", { headers: { authorization: "Bearer x" } }); | |
| 107 | expect(crossOriginAllowed(anonymous, "/assets/app.css")).toBe(true); | |
| 108 | expect(crossOriginAllowed(signed, "/assets/app.css")).toBe(true); | |
| 109 | expect(crossOriginAllowed(anonymous, "/ada/notes/activity.atom")).toBe(true); | |
| 110 | expect(crossOriginAllowed(signed, "/ada/notes/activity.atom")).toBe(false); | |
| 111 | expect(crossOriginAllowed(anonymous, "/api/v1/user")).toBe(false); | |
| 112 | expect(crossOriginAllowed(anonymous, "/ada/notes/raw/main/a.txt")).toBe(false); | |
| 113 | expect(crossOriginAllowed(anonymous, "/x/assets/a.css")).toBe(false); | |
| 114 | }); |