Skip to content

Commit 4fcf3d5

Browse files
committed
fix(viewer): render rich parts same-origin with a nonce CSP
Chrome 149 field-trial variants can defer layout in opaque-origin srcdoc frames, leaving rich surfaces blank or clipped even after the existing reparse retry. Render rich srcdoc frames same-origin to avoid the affected layout path and replace unsafe-inline with a fresh per-document CSP nonce. Only the trusted resize and interaction bridge receives the nonce; rendered part bodies remain non-executable. Move code-part copy behavior into that bridge so code surfaces remain functional under the stricter policy. Add browser coverage proving injected scripts and handlers stay blocked while the bridge and code-copy path work.
1 parent c2e4443 commit 4fcf3d5

20 files changed

Lines changed: 237 additions & 109 deletions

.changeset/chrome-srcdoc-retry.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"sideshow": patch
33
---
44

5-
Fix invisible markdown/mermaid/diff/terminal surfaces caused by a Chrome field trial that breaks layout measurement in opaque-origin srcdoc iframes. The viewer now retries the srcdoc parse after 2 seconds if the iframe is still stuck at minimum height.
5+
Fix rich surfaces that intermittently render blank or clipped under a Chrome field trial. Rich `srcdoc` frames now avoid the affected opaque-origin layout path, while a per-document CSP nonce limits script execution to the trusted resize and interaction bridge.

AGENTS.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ consciously, not as a side effect):
5151
wraps an html part (CDN-allowlist CSP + the postMessage bridge: resize,
5252
sendPrompt, openLink) and injects any opted-in kits (`kits.ts`).
5353
`renderSandboxedPart` wraps markup the viewer rendered
54-
to a string (markdown/mermaid/diff/terminal) under a tighter CSP (no
55-
`connect-src`, no CDN) — see `viewer/src/SandboxedPart.tsx`. Image and trace
56-
parts stay native because they have no HTML sink (the viewer renders them with
57-
text nodes / `<img>` / JSX). No agent markup is ever set as `innerHTML` in the
58-
trusted viewer origin.
54+
to a string (markdown/mermaid/diff/terminal/code) under a tighter CSP (nonce-only
55+
bridge script, no `connect-src`, no CDN) — see
56+
`viewer/src/SandboxedPart.tsx`. Image and trace parts stay native because
57+
they have no HTML sink (the viewer renders them with text nodes / `<img>` /
58+
JSX). No agent markup is ever set as `innerHTML` in the trusted viewer origin.
5959
- `server/themes.ts` — theme registry (github/gruvbox/one), runtime-agnostic so
6060
both server and viewer import it. One `Palette` per light/dark per theme; the
6161
viewer-chrome vars and the html-part `--color-*` tokens are both _derived_
@@ -91,15 +91,17 @@ consciously, not as a side effect):
9191
user, and inject prompts back to the agent. The rule applies to every part
9292
kind, comments, and anything else agent-authored. The two safe ways to render
9393
it: (a) **build a STRING and hand it to a sandbox iframe**`SandboxedPart`
94-
for viewer-rendered parts (markdown/mermaid/diff/terminal, comments) and
94+
for viewer-rendered parts (markdown/mermaid/diff/terminal/code, comments) and
9595
`renderHtmlPage` at `/s/:id` for html parts; or (b) **keep it as data and
9696
render with Solid text nodes / element attributes**, which escape by
9797
construction (image, trace). String-building in the viewer is fine — a string
9898
is not a DOM sink; danger only starts when it reaches the DOM, which must
99-
happen at an opaque origin. When you add a part kind, pick (a) or (b); never a
100-
third way. The iframes are sandboxed without `allow-same-origin` (opaque
101-
origin) and `connect-src`-free for rich parts (no exfil even if contained
102-
script runs); never weaken this.
99+
happen inside a sandboxed iframe. When you add a part kind, pick (a) or (b);
100+
never a third way. HTML-part iframes stay sandboxed without
101+
`allow-same-origin` (opaque origin). Rich `srcdoc` iframes use
102+
`allow-same-origin` to avoid Chrome's opaque-origin srcdoc layout bug, so
103+
their CSP must stay nonce-only for scripts and `connect-src`-free; never
104+
weaken this.
103105
- WebKit quirk in sandboxed iframes: ResizeObserver's initial callback may not
104106
fire and `documentElement.scrollHeight` ratchets to viewport height — the
105107
bridge reports `body.scrollHeight` on `load` plus staggered timers. Don't

e2e/code.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { expect, publishParts, test } from "./fixtures.ts";
2+
3+
test("a code part keeps copy behavior under the nonce-only rich-frame CSP", async ({
4+
page,
5+
server,
6+
context,
7+
browserName,
8+
}) => {
9+
const code = `const closingTag = "</script>";\nconsole.log(closingTag);`;
10+
await publishParts(server.url, {
11+
title: "code",
12+
parts: [{ kind: "code", code, language: "ts", title: "example.ts", lineStart: 40 }],
13+
});
14+
if (browserName === "chromium") {
15+
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
16+
}
17+
await page.goto(server.url);
18+
19+
const card = page.locator(".card:not(#whatsNew)").first();
20+
await expect(card.locator("iframe.codeframe")).toHaveAttribute(
21+
"sandbox",
22+
"allow-scripts allow-same-origin",
23+
);
24+
const frame = card.frameLocator("iframe.codeframe");
25+
await expect(frame.locator(".code-filename")).toContainText("example.ts:40-41");
26+
await frame.locator(".copy-btn").click();
27+
await expect(frame.locator(".copy-btn")).toHaveText("Copied!");
28+
if (browserName === "chromium") {
29+
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(code);
30+
}
31+
});

e2e/diff.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ test("a diff part renders a highlighted diff inside a sandbox iframe", async ({
2121
await page.goto(server.url);
2222
const card = page.locator(".card:not(#whatsNew)").first();
2323

24-
// the diff renders in an opaque-origin sandbox iframe (no allow-same-origin),
25-
// so a @pierre/diffs DOM-building regression can't reach the board
26-
await expect(card.locator("iframe.diffframe")).toHaveAttribute("sandbox", "allow-scripts");
24+
// the diff renders in a sandbox iframe. It is same-origin to avoid Chrome
25+
// 149's opaque-origin srcdoc layout bug; CSP blocks all non-nonced scripts.
26+
await expect(card.locator("iframe.diffframe")).toHaveAttribute(
27+
"sandbox",
28+
"allow-scripts allow-same-origin",
29+
);
2730
const frame = card.frameLocator("iframe.diffframe");
2831

2932
// the @pierre/diffs SSR fragment mounts in a declarative shadow root; its

e2e/isolation.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, publish, test } from "./fixtures.ts";
2+
import { renderSandboxedPart } from "../server/surfacePage.ts";
23

34
// The sandbox attribute (asserted across the part specs) is the *shape* of the
45
// isolation; this spec asserts the *behavior* the project's core invariant
@@ -34,3 +35,47 @@ test("an html part's script is CSP-blocked from fetching the board API", async (
3435
// and it must never have succeeded
3536
await expect(probe).not.toContainText("LEAKED");
3637
});
38+
39+
test("a rich frame runs only its nonced bridge, not injected scripts or handlers", async ({
40+
page,
41+
server,
42+
}) => {
43+
const srcdoc = renderSandboxedPart({
44+
body: `<div id="probe">content</div>
45+
<script>parent.document.body.dataset.richEscape = 'script'</script>
46+
<img src="data:image/png;base64,!" onerror="parent.document.body.dataset.richEscape = 'handler'">`,
47+
css: "",
48+
origin: server.url,
49+
});
50+
51+
await page.goto(server.url);
52+
await page.evaluate((doc) => {
53+
document.body.replaceChildren();
54+
delete document.body.dataset.richEscape;
55+
(window as Window & { richBridgeReady?: boolean }).richBridgeReady = false;
56+
window.addEventListener("message", (event) => {
57+
if (event.data?.__sideshow && event.data.type === "resize") {
58+
(window as Window & { richBridgeReady?: boolean }).richBridgeReady = true;
59+
}
60+
});
61+
const frame = document.createElement("iframe");
62+
frame.id = "rich-probe";
63+
frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
64+
frame.srcdoc = doc;
65+
document.body.append(frame);
66+
}, srcdoc);
67+
68+
await expect(page.frameLocator("#rich-probe").locator("#probe")).toHaveText("content");
69+
await expect.poll(() => page.evaluate(() => document.body.dataset.richEscape)).toBeUndefined();
70+
await expect
71+
.poll(() =>
72+
page.evaluate(
73+
() => (window as Window & { richBridgeReady?: boolean }).richBridgeReady === true,
74+
),
75+
)
76+
.toBe(true);
77+
await expect(page.locator("#rich-probe")).toHaveAttribute(
78+
"sandbox",
79+
"allow-scripts allow-same-origin",
80+
);
81+
});

e2e/markdown.spec.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ test("a markdown part renders typed prose with a highlighted code block", async
2828
await page.goto(server.url);
2929
const card = page.locator(".card");
3030

31-
// markdown renders inside an opaque-origin sandbox iframe (defense in depth:
32-
// even a markdown-it/shiki regression can't reach the board). The sandbox has
33-
// NO allow-same-origin — that's the isolation guarantee.
34-
await expect(card.locator("iframe.mdframe")).toHaveAttribute("sandbox", "allow-scripts");
31+
// markdown renders inside a sandbox iframe. It is same-origin to avoid Chrome
32+
// 149's opaque-origin srcdoc layout bug; CSP blocks all non-nonced scripts.
33+
await expect(card.locator("iframe.mdframe")).toHaveAttribute(
34+
"sandbox",
35+
"allow-scripts allow-same-origin",
36+
);
3537
const md = card.frameLocator("iframe.mdframe");
3638

3739
// structured typography inside the frame

e2e/mermaid.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ test("a mermaid part renders a diagram as inline SVG in the viewer", async ({ pa
1818
const card = page.locator(".card:not(#sessionThread)");
1919
const mermaid = card.locator(".mermaidpart");
2020

21-
// the diagram renders inside an opaque-origin sandbox iframe — a second
22-
// boundary behind mermaid's DOMPurify. No allow-same-origin.
23-
await expect(mermaid.locator("iframe.mermaidframe")).toHaveAttribute("sandbox", "allow-scripts");
21+
// the diagram renders inside a sandbox iframe. It is same-origin to avoid
22+
// Chrome 149's opaque-origin srcdoc layout bug; CSP blocks non-nonced scripts.
23+
await expect(mermaid.locator("iframe.mermaidframe")).toHaveAttribute(
24+
"sandbox",
25+
"allow-scripts allow-same-origin",
26+
);
2427
const frame = mermaid.frameLocator("iframe.mermaidframe");
2528

2629
const svg = frame.locator("svg");

e2e/terminal.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ test("a terminal part renders ANSI in a sandbox iframe, escaping raw HTML", asyn
1717
await page.goto(server.url);
1818
const card = page.locator(".card:not(#whatsNew)").first();
1919

20-
// terminal output renders inside an opaque-origin sandbox iframe — ansi_up's
21-
// escaping is no longer the only thing between agent text and the board
22-
await expect(card.locator("iframe.termframe")).toHaveAttribute("sandbox", "allow-scripts");
20+
// terminal output renders inside a sandbox iframe. It is same-origin to avoid
21+
// Chrome 149's opaque-origin srcdoc layout bug; CSP blocks non-nonced scripts.
22+
await expect(card.locator("iframe.termframe")).toHaveAttribute(
23+
"sandbox",
24+
"allow-scripts allow-same-origin",
25+
);
2326
const frame = card.frameLocator("iframe.termframe");
2427

2528
// SGR escape became an inline-styled span (a color), not literal text

e2e/viewer.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ test("comment typed in the composer round-trips to the API", async ({ page, serv
112112
await input.press("Enter");
113113

114114
// renders in the thread (via SSE) and is persisted server-side. The comment
115-
// text renders inside its own opaque-origin sandbox iframe.
115+
// text renders inside its own sandbox iframe.
116116
await expect(card.frameLocator(".cmtframe").locator("body")).toContainText("ship it");
117117
await expect(card.locator(".cmt .who")).toHaveText("you");
118118
await expect
@@ -226,8 +226,11 @@ test("a comment containing raw HTML is sandboxed and escaped, never a live node"
226226
await input.fill("<img src=x onerror=alert(1)> hi");
227227
await input.press("Enter");
228228

229-
// the comment renders inside an opaque-origin sandbox iframe...
230-
await expect(card.locator(".cmtframe")).toHaveAttribute("sandbox", "allow-scripts");
229+
// the comment renders inside a sandbox iframe with nonce-gated scripts.
230+
await expect(card.locator(".cmtframe")).toHaveAttribute(
231+
"sandbox",
232+
"allow-scripts allow-same-origin",
233+
);
231234
const frame = card.frameLocator(".cmtframe");
232235
// ...with the raw HTML escaped to text, never a live <img>
233236
await expect(frame.locator("img")).toHaveCount(0);

server/surfacePage.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,8 @@ const SVG_DEFS = `<svg width="0" height="0" style="position:absolute" aria-hidde
131131

132132
// Bridge to the host viewer: sendPrompt/openLink/copyToClipboard mirror
133133
// Claude's widget globals, and a ResizeObserver reports content height so the
134-
// parent can size the sandboxed (opaque-origin) iframe. copyToClipboard posts
135-
// to the parent (trusted origin) which has clipboard API access; the sandbox
136-
// itself is opaque-origin so navigator.clipboard is unavailable there.
134+
// parent can size the sandboxed iframe. copyToClipboard posts to the parent
135+
// because clipboard access belongs to the trusted viewer, not surface markup.
137136
const BRIDGE_JS = `
138137
window.sendPrompt = function (text) {
139138
parent.postMessage({ __sideshow: true, type: 'send-prompt', text: String(text) }, '*');
@@ -147,6 +146,18 @@ window.copyToClipboard = function (text) {
147146
document.addEventListener('click', function (e) {
148147
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
149148
if (a && /^https?:/.test(a.href)) { e.preventDefault(); window.openLink(a.href); }
149+
var copy = e.target && e.target.closest ? e.target.closest('[data-sideshow-copy]') : null;
150+
if (copy) {
151+
var wrap = copy.closest('.code-wrap');
152+
var source = wrap && wrap.querySelector('.code-copy-source');
153+
window.copyToClipboard(source ? source.textContent : '');
154+
copy.textContent = 'Copied!';
155+
copy.classList.add('copied');
156+
setTimeout(function () {
157+
copy.textContent = 'Copy';
158+
copy.classList.remove('copied');
159+
}, 1500);
160+
}
150161
});
151162
// Cmd+Option+Up/Down switches sessions in the sidebar, but keydowns fire in
152163
// whichever document holds focus — once the user clicks into a snippet, this
@@ -182,34 +193,56 @@ if (window.ResizeObserver) {
182193
export const escapeHtml = (s: string) =>
183194
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
184195

196+
function randomNonce(): string {
197+
const bytes = new Uint8Array(16);
198+
const webCrypto = (
199+
globalThis as typeof globalThis & {
200+
crypto: { getRandomValues(array: Uint8Array): Uint8Array };
201+
}
202+
).crypto;
203+
webCrypto.getRandomValues(bytes);
204+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
205+
let out = "";
206+
for (let i = 0; i < bytes.length; i += 3) {
207+
const a = bytes[i]!;
208+
const b = bytes[i + 1];
209+
const c = bytes[i + 2];
210+
out += alphabet[a >> 2];
211+
out += alphabet[((a & 3) << 4) | ((b ?? 0) >> 4)];
212+
out += b === undefined ? "=" : alphabet[((b & 15) << 2) | ((c ?? 0) >> 6)];
213+
out += c === undefined ? "=" : alphabet[c & 63];
214+
}
215+
return out;
216+
}
217+
185218
// Wrap one html part in the themed, sandboxed document the iframe loads. The
186219
// board's color tokens (theme-dependent) are injected first so the static base
187220
// + kit resolve against them; `theme` defaults to the github preset.
188-
// CSP for a rich part (markdown/mermaid/diff). These render markup our own
221+
// CSP for a rich part (markdown/mermaid/diff/terminal/code). These render markup our own
189222
// libraries produced — they never load CDN scripts and never need the network,
190-
// so the policy is *tighter* than an html part's: only the inline bridge runs,
191-
// and there is no `connect-src`, so even if a sanitizer regression let agent
192-
// markup execute, the script is boxed into an opaque origin with no way to
193-
// phone home. `img-src origin` lets inline markdown images at <origin>/a/:id
194-
// load (the iframe is opaque-origin, so `'self'` matches nothing — same reason
195-
// buildCsp adds it explicitly).
196-
function buildRichCsp(origin: string): string {
223+
// so the policy is *tighter* than an html part's: only the nonce-bearing bridge
224+
// runs, and there is no `connect-src`. The frame uses allow-same-origin to avoid
225+
// Chrome 149's opaque-origin srcdoc layout bug, so injected scripts must not be
226+
// able to run at all. `img-src origin` lets inline markdown images at
227+
// <origin>/a/:id load without making the board origin a script/connect source.
228+
function buildRichCsp(origin: string, nonce: string): string {
197229
return [
198230
`default-src 'none'`,
199-
`script-src 'unsafe-inline'`,
231+
`script-src 'nonce-${nonce}'`,
200232
`style-src 'unsafe-inline'`,
201233
`img-src https: data: blob: ${origin}`,
202234
`font-src data:`,
203235
].join("; ");
204236
}
205237

206238
// Wrap pre-rendered, *untrusted* markup (markdown HTML, a mermaid SVG, a diff's
207-
// SSR output) in the same opaque-origin sandbox html parts get. The markup was
208-
// built as a STRING in the trusted viewer (string building is not a DOM sink),
209-
// and only becomes live DOM here, inside the iframe — so a markdown-it / shiki /
210-
// mermaid / DOMPurify / @pierre-diffs sanitizer bypass can no longer reach the
211-
// board. `css` is the part-specific stylesheet (prose/diff/mermaid rules);
212-
// chrome theme vars come from viewerThemeCss so the part matches the viewer.
239+
// SSR output) in a sandboxed iframe document. The markup was built as a STRING
240+
// in the trusted viewer (string building is not a DOM sink), and only becomes
241+
// live DOM here, inside a document whose CSP allows only the nonce-bearing
242+
// bridge script — so a markdown-it / shiki / mermaid / DOMPurify /
243+
// @pierre-diffs sanitizer bypass can no longer run script in the board origin.
244+
// `css` is the part-specific stylesheet (prose/diff/mermaid rules); chrome
245+
// theme vars come from viewerThemeCss so the part matches the viewer.
213246
export function renderSandboxedPart(doc: {
214247
body: string;
215248
css: string;
@@ -218,12 +251,13 @@ export function renderSandboxedPart(doc: {
218251
}): string {
219252
const theme =
220253
typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme;
254+
const nonce = randomNonce();
221255
return `<!doctype html>
222256
<html lang="en">
223257
<head>
224258
<meta charset="utf-8">
225259
<meta name="viewport" content="width=device-width, initial-scale=1">
226-
<meta http-equiv="Content-Security-Policy" content="${buildRichCsp(doc.origin)}">
260+
<meta http-equiv="Content-Security-Policy" content="${buildRichCsp(doc.origin, nonce)}">
227261
<!-- srcdoc's base URL is about:srcdoc, so relative URLs (e.g. a markdown
228262
image at /a/:id) would not resolve; pin the base to the server origin.
229263
img-src in buildRichCsp allows that origin. (html parts don't need this —
@@ -233,7 +267,7 @@ export function renderSandboxedPart(doc: {
233267
</head>
234268
<body>
235269
${doc.body}
236-
<script>${BRIDGE_JS}</script>
270+
<script nonce="${nonce}">${BRIDGE_JS}</script>
237271
</body>
238272
</html>`;
239273
}

0 commit comments

Comments
 (0)