Public: anyone can read this; the crew can change it.
A new app from the template sends Cross-Origin-Opener-Policy: same-origin, and the web spec check asks for it #3991
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3399-a-new-app-from-the-template-sends-cross → main 5 commits fork 1 thread · 1 unresolved
4 files
changed +19
−0
src/apps/scaffold.ts +5
| @@ -150,6 +150,10 @@ bun run contract:emit # rewrite contract/opena | ||
| 150 | 150 | it: the preview comes up the moment the app answers, and says \`live\`, or |
| 151 | 151 | \`serving · tests failed\` the moment the suite disagrees. |
| 152 | 152 | |
| 153 | Every response carries \`Content-Security-Policy\`, \`X-Content-Type-Options\`, | |
| 154 | \`Referrer-Policy\` and \`Cross-Origin-Opener-Policy: same-origin\` (the last | |
| 155 | cuts a page off from any window that opened it); they are set in \`src/http.ts\`. | |
| 156 | ||
| 153 | 157 | ## The shape |
| 154 | 158 | |
| 155 | 159 | One implementation, many doors. \`src/actions.ts\` is every operation as a |
| @@ -1376,6 +1380,7 @@ export const SECURITY: Record<string, string> = { | ||
| 1376 | 1380 | "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", |
| 1377 | 1381 | "x-content-type-options": "nosniff", |
| 1378 | 1382 | "referrer-policy": "strict-origin-when-cross-origin", |
| 1383 | "cross-origin-opener-policy": "same-origin", | |
| 1379 | 1384 | "permissions-policy": "camera=(), microphone=(), geolocation=()", |
| 1380 | 1385 | ...(publicOrigin.startsWith("https:") |
| 1381 | 1386 | ? { "strict-transport-security": "max-age=31536000; includeSubDomains" } |
src/release/webspec.ts +1
| @@ -177,6 +177,7 @@ export function headerProblems(h: Headers, https: boolean): Problem[] { | ||
| 177 | 177 | if ((h.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") add("nosniff", "X-Content-Type-Options is not nosniff", "nosniff"); |
| 178 | 178 | if (!h.get("referrer-policy")) add("referrer-policy", "no Referrer-Policy", "referrer-policy"); |
| 179 | 179 | if (!h.get("permissions-policy")) add("permissions-policy", "no Permissions-Policy", "permissions-policy"); |
| 180 | if (!h.get("cross-origin-opener-policy")) add("coop", "no Cross-Origin-Opener-Policy", "coop", true); | |
| 180 | 181 | if (https && !h.get("strict-transport-security")) add("hsts", "no Strict-Transport-Security on an https URL", "hsts", true); |
| 181 | 182 | if (h.has("x-xss-protection")) add("x-xss-protection", "X-XSS-Protection is set; drop it", "x-xss-protection"); |
| 182 | 183 | if (/\bimmutable\b/i.test(h.get("cache-control") ?? "")) add("cache-html", "HTML is cached immutable; only hashed assets may be", "caching"); |
test/scaffold-webspec.test.ts +7
| @@ -17,6 +17,7 @@ const KNOWN: string[] = []; | ||
| 17 | 17 | let dir: string; |
| 18 | 18 | let child: ReturnType<typeof Bun.spawn> | undefined; |
| 19 | 19 | let findings: Finding[] = []; |
| 20 | let served: Headers = new Headers(); | |
| 20 | 21 | |
| 21 | 22 | beforeAll(async () => { |
| 22 | 23 | dir = await mkdtemp(join(tmpdir(), "forge-scaffold-webspec-")); |
| @@ -48,6 +49,7 @@ beforeAll(async () => { | ||
| 48 | 49 | if (Date.now() > end) throw new Error("the scaffold did not boot"); |
| 49 | 50 | await Bun.sleep(50); |
| 50 | 51 | } |
| 52 | served = (await fetch(`${base}/`)).headers; | |
| 51 | 53 | findings = await webspec(base, ["/"], fetch, { site: true }); |
| 52 | 54 | }, 30_000); |
| 53 | 55 | |
| @@ -61,3 +63,8 @@ test("a fresh scaffold's webspec findings are exactly the known gaps", () => { | ||
| 61 | 63 | const got = [...new Set(findings.map((f) => f.check))].sort(); |
| 62 | 64 | expect(got).toEqual([...KNOWN].sort()); |
| 63 | 65 | }); |
| 66 | ||
| 67 | test("a fresh scaffold sends Cross-Origin-Opener-Policy: same-origin", () => { | |
| 68 | expect(findings.map((f) => f.check)).not.toContain("coop"); | |
| 69 | expect(served.get("cross-origin-opener-policy")).toBe("same-origin"); | |
| 70 | }); |
test/webspec.test.ts +6
| @@ -20,6 +20,7 @@ const GOOD_HEADERS: Record<string, string> = { | ||
| 20 | 20 | "x-content-type-options": "nosniff", |
| 21 | 21 | "referrer-policy": "same-origin", |
| 22 | 22 | "permissions-policy": "camera=()", |
| 23 | "cross-origin-opener-policy": "same-origin", | |
| 23 | 24 | "strict-transport-security": "max-age=63072000", |
| 24 | 25 | "cache-control": "no-cache", |
| 25 | 26 | etag: '"v1"', |
| @@ -90,6 +91,11 @@ test("style-src unsafe-inline is not a script finding", async () => { | ||
| 90 | 91 | expect(await checks(undefined, { ...GOOD_HEADERS, "content-security-policy": csp })).toEqual([]); |
| 91 | 92 | }); |
| 92 | 93 | |
| 94 | test("a missing Cross-Origin-Opener-Policy is an edge finding", async () => { | |
| 95 | const found = await webspec("http://x.test", ["/"], site(GOOD_HTML, without("cross-origin-opener-policy"))); | |
| 96 | expect(found.map((f) => [f.check, f.edge])).toEqual([["coop", true]]); | |
| 97 | }); | |
| 98 | ||
| 93 | 99 | test("HSTS is an edge finding on https only", async () => { |
| 94 | 100 | const found = await webspec("https://x.test", ["/"], site(GOOD_HTML, without("strict-transport-security"))); |
| 95 | 101 | expect(found.map((f) => [f.check, f.edge])).toEqual([["hsts", true]]); |
new above
offline
The comment above SECURITY explains each header but not this one. Add a clause saying COOP cuts the page off from any window that opened it.