Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/iframe-csrf-headers.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 8 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 18 additions & 1 deletion test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
}
});

Expand All @@ -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, /<p>diagram<\/p>/, "should not inline agent HTML");
Expand All @@ -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, /<title>Auth refactor · sideshow<\/title>/);
Expand Down
15 changes: 15 additions & 0 deletions test/notes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderNotes } from "../viewer/src/notes.ts";

// Release notes come from the GitHub release at runtime and render in the trusted
// viewer chrome. An external link must open with BOTH noopener (no window.opener
// reverse-tabnabbing) and noreferrer (the viewer URL — which can carry the `?key=`
// deploy token — never leaks via Referer to the destination site).
test("renderNotes external links carry rel=noopener noreferrer", () => {
const html = renderNotes("See [the docs](https://example.com/x) for details.");
assert.match(
html,
/<a href="https:\/\/example\.com\/x" target="_blank" rel="noopener noreferrer">the docs<\/a>/,
);
});
4 changes: 2 additions & 2 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ function StandaloneView(props: { post: Post }) {
<main class="standalone-main">
<Card post={props.post} standalone />
<footer class="standalone-foot">
<a href="https://sideshow.sh" target="_blank" rel="noopener">
<a href="https://sideshow.sh" target="_blank" rel="noopener noreferrer">
made with <strong>sideshow</strong>
</a>
</footer>
Expand Down Expand Up @@ -465,7 +465,7 @@ async function onBridgeMessage(ev: MessageEvent) {
}
if (link.protocol !== "http:" && link.protocol !== "https:") return;
if (confirm(`Open external link?\n\n${link.href}`))
window.open(link.href, "_blank", "noopener");
window.open(link.href, "_blank", "noopener,noreferrer");
} else if (d.type === "copy" && isOwnFrame(ev.source)) {
void navigator.clipboard?.writeText(String(d.text)).catch(() => {});
}
Expand Down
2 changes: 2 additions & 0 deletions viewer/src/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ export function Card(props: { post: Post; standalone?: boolean }) {
<a
class="act icon open"
target="_blank"
rel="noopener"
href={postLink(props.post.id)}
title="Open in a new tab"
aria-label="Open in a new tab"
Expand All @@ -443,6 +444,7 @@ export function Card(props: { post: Post; standalone?: boolean }) {
<a
class="act icon shot"
target="_blank"
rel="noopener"
href={postImageLink(props.post.id)}
title="Open first surface as an image (PNG)"
aria-label="Open first surface as an image (PNG)"
Expand Down
2 changes: 1 addition & 1 deletion viewer/src/ImageSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function ImageSurface(props: { surface: ImageSurfaceData }) {
when={!failed()}
fallback={<div class="asset-gone">Image unavailable — it may have been evicted.</div>}
>
<a href={src()} target="_blank" rel="noopener">
<a href={src()} target="_blank" rel="noopener noreferrer">
<img
class="asset-img"
src={src()}
Expand Down
2 changes: 1 addition & 1 deletion viewer/src/TraceSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function TraceSurface(props: { surface: TraceSurfaceData }) {
<span class="trace-title">{props.surface.title ?? "Agent trace"}</span>
<Show when={assetUrl()} keyed>
{(url) => (
<a class="trace-dl" href={url} target="_blank" rel="noopener">
<a class="trace-dl" href={url} target="_blank" rel="noopener noreferrer">
download ↓
</a>
)}
Expand Down
2 changes: 1 addition & 1 deletion viewer/src/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const inline = (s: string) =>
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(
/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>',
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
);

export function renderNotes(md: string): string {
Expand Down
Loading