diff --git a/.changeset/iframe-csrf-headers.md b/.changeset/iframe-csrf-headers.md new file mode 100644 index 0000000..0146a3b --- /dev/null +++ b/.changeset/iframe-csrf-headers.md @@ -0,0 +1,13 @@ +--- +"sideshow": patch +--- + +Harden the trusted viewer against clickjacking and referrer leaks. The viewer +HTML (the app origin, shared with the authenticated API and the comment→agent +channel) now sends `Content-Security-Policy: frame-ancestors 'self'`, refusing +cross-origin framing; the sandboxed `/s/:id` surface documents are unaffected +and keep their own `sandbox` CSP. External links the viewer opens — the +`openLink` bridge's `window.open`, the release-notes markdown links, and the +image/trace/footer anchors — now use `rel="noopener noreferrer"` (and the +`noreferrer` window feature), so the current URL (which can carry the `?key=` +deploy token) never rides an outbound `Referer`. diff --git a/server/app.ts b/server/app.ts index 310ba55..2e55df7 100644 --- a/server/app.ts +++ b/server/app.ts @@ -923,6 +923,14 @@ export function createApp({ }; const configuredViewerHtml = (c: Context, opts: { post?: Post; title?: string | null } = {}) => { + // The viewer HTML is the trusted app origin — it shares that origin with the + // authenticated API and the comment→agent channel, so a cross-origin page + // that frames it could clickjack actions or the prompt-injection channel. + // Refuse cross-origin framing (same-origin embedding still allowed). This is + // the trusted shell only; the sandboxed surface documents at /s/:id?part=N + // are *meant* to be framed and carry their own `sandbox` CSP header instead, + // so they never pass through here and are unaffected. + c.header("Content-Security-Policy", "frame-ancestors 'self'"); const pageTitle = opts.post?.title ?? opts.title; const html = withDocumentTitle( withViewerConfig( diff --git a/test/api.test.ts b/test/api.test.ts index eabb8e2..a5765ec 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -230,6 +230,10 @@ test("publishes a combined html+diff surface; /s server-renders both surfaces op assert.match(csp, /\bsandbox\b/); assert.match(csp, /\ballow-scripts\b/); assert.doesNotMatch(csp, /allow-same-origin/); + // Surface docs are MEANT to be framed by the viewer, so they must never pick + // up the viewer shell's anti-clickjacking frame-ancestors (that would refuse + // the embedding iframe). The two CSPs are mutually exclusive by construction. + assert.doesNotMatch(csp, /frame-ancestors/); } }); @@ -253,7 +257,12 @@ test("GET /s/:id serves the viewer shell with link-preview metadata", async () = const page = await app.request(`https://board.test/s/${surface.id}`); assert.equal(page.status, 200); assert.ok(page.headers.get("content-type")?.includes("text/html")); - assert.equal(page.headers.get("content-security-policy"), null); + // The trusted viewer shell refuses cross-origin framing (anti-clickjacking); + // it carries frame-ancestors only, never the sandbox CSP the /s/:id?part=N + // surface documents use. + const shellCsp = page.headers.get("content-security-policy") ?? ""; + assert.match(shellCsp, /frame-ancestors 'self'/); + assert.doesNotMatch(shellCsp, /\bsandbox\b/); const body = await page.text(); assert.ok(body.includes("viewer"), "should serve the trusted viewer shell"); assert.doesNotMatch(body, /
diagram<\/p>/, "should not inline agent HTML"); @@ -279,6 +288,14 @@ test("GET /session/:id serves the viewer shell with the session title", async () const page = await app.request(`/session/${surface.sessionId}`); assert.equal(page.status, 200); assert.ok(page.headers.get("content-type")?.includes("text/html")); + // Every viewer-HTML route (here, `/` and `/session/:id`) is anti-clickjacking + // framed, sharing the one chokepoint (configuredViewerHtml). + assert.match(page.headers.get("content-security-policy") ?? "", /frame-ancestors 'self'/); + const root = await app.request("/"); + assert.match(root.headers.get("content-security-policy") ?? "", /frame-ancestors 'self'/); + // The nested post-permalink alias shares the same configuredViewerHtml chokepoint. + const aliased = await app.request(`/session/${surface.sessionId}/p/${surface.id}`); + assert.match(aliased.headers.get("content-security-policy") ?? "", /frame-ancestors 'self'/); const body = await page.text(); assert.ok(body.includes("viewer"), "should serve the trusted viewer shell"); assert.match(body, /