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

Merged opened this

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

Merged

scout/forge:scout/10965-every-html-json-and-plain-text-answer-ca → main 3 commits fork

  • Merged. scout/10965-every-html-json-and-plain-text-answer-ca was merged into main as c73e1f6.

Review

Sign in to review this change.
5 files changed +190 −1
docs/SECURITY-RUNBOOK.md +14
@@ -168,6 +168,20 @@ unset FORGE_TOKEN
168168
169169 **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.
170170
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
171185 ## After any of these
172186
173187 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 {
225225 headers.set("strict-transport-security", HSTS);
226226 if (!headers.has("cross-origin-opener-policy"))
227227 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");
228232 // What left decides, not what was asked for: an HTML body writes the
229233 // only policy that can name its own nonce, and `default-src 'none'`
230234 // 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";
481481 import { mcpRoutes, REPORT_TOOL } from "./mcp.ts";
482482 import { TOOLS } from "./mcp/tools.ts";
483483 import { setMcpTools } from "../web/llms_tools.ts";
484 import { resourcePolicy } from "./corp.ts";
484485 import { agentsPage } from "../web/pages/agents.ts";
485486 import { openapiRoute } from "../contract/served.ts";
486487 import { createUsageCounter, type UsageCounter } from "../core/usage_count.ts";
@@ -2500,7 +2501,7 @@ export async function serve(app: App): Promise<Servers> {
25002501 if (url.pathname.startsWith("/api/v1/"))
25012502 response.headers.set("www-authenticate", bearerChallenge(url.origin, request));
25022503 }
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));
25042505 if (namesFrame(request)) notePhase(request, "compress", performance.now() - compressing);
25052506 return done;
25062507 },
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs