Studio: keep HTML preview on srcdoc, document the CSP-inheritance limit

The previous commit swapped HtmlPreview to a blob: URL on the basis
of a round-4 reviewer claim that Chromium blob: frames get a fresh
policy container. Empirically verified against the live Studio: blob:
iframes ALSO inherit the embedder CSP in Chromium (and the spec
confirms it -- HTML / CSP3 § initialize-document-csp inherits CSP
for srcdoc, data:, and blob: alike). With the host enforcing
``script-src 'self'``, assistant inline scripts and on* handlers are
blocked in all three. Probe screenshots: alert dialog does not fire,
console shows ``script-src 'self'`` violations originating from
blob:http://127.0.0.1:8901/...

Switch back to srcdoc, which is the simplest path with the same
script-execution behavior. The meta-CSP inside the iframe stays as
defense in depth (connect-src 'none', frame-src 'none', img-src
data: blob:, etc.) so even a future host-CSP relaxation cannot turn
the preview into an exfiltration channel. allow-popups +
allow-popups-to-escape-sandbox stay so target=_blank links open
proper new tabs.

Genuinely interactive HTML demos need a same-origin backend route
serving with response-header CSP (response headers do NOT inherit
from the embedder); that is tracked as a follow-up. For this PR the
HTML preview ships as a static-render surface for layout, styles,
images, and source-tab viewing. The PR's manual-test claim about
``button onclick=alert(1)`` running inside the iframe is corrected
to "renders without firing the click handler under the current host
CSP" -- explicitly noted in the in-file comment and the meta-CSP
keeps ``script-src 'unsafe-inline'`` declared so the day the host
route lands the inner contract is already correct.

frame-src in the host CSP is reverted to ``'self'`` only.
This commit is contained in:
Daniel Han 2026-05-25 08:13:00 +00:00
commit 115ff8978c
4 changed files with 73 additions and 45 deletions

View file

@ -328,8 +328,15 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
f"{script_src}; "
"font-src 'self' data:; "
# Restrict iframe sources to same-origin only. The assistant
# HTML/SVG previews use srcdoc (no URL involved) and inherit this
# CSP, so this also bounds what the preview iframe can do.
# HTML/SVG previews use srcdoc (no URL fetch), so allowing
# data: / blob: here would not unlock interactive scripts
# anyway -- Chromium inherits the embedder CSP for srcdoc,
# data:, AND blob: iframes per HTML / CSP3, so the only way
# to escape ``script-src 'self'`` for the preview would be a
# same-origin backend route serving with overriding response
# CSP headers. Tracked as a follow-up; for now the explicit
# ``'self'`` setting leaves a visible directive that grep
# picks up if a future change tries to relax it.
"frame-src 'self'; "
"frame-ancestors 'none'; "
"form-action 'self'; "

View file

@ -197,11 +197,12 @@ class TestSecurityHeadersMiddleware:
assert "script-src 'self' 'nonce-XYZ';" in nonced
def test_frame_src_is_explicitly_self_only(self, main_module):
# The assistant HTML/SVG preview iframe uses srcdoc (no URL fetch),
# so frame-src does not need to permit data: / blob:. Pinning to
# 'self' explicitly is the strictest setting CSP allows here, and
# leaves a visible directive a reviewer can grep for if a future
# change tries to relax it without an audit.
# The assistant HTML/SVG preview iframe uses srcdoc (no URL
# fetch). Allowing data: / blob: here would not unlock inline
# scripts anyway -- Chromium inherits the embedder CSP for
# srcdoc, data:, AND blob: iframes per HTML / CSP3. The
# explicit 'self' here leaves a visible directive any future
# change has to deliberately broaden.
csp = main_module._build_csp()
frame_src = next(
chunk.strip()

View file

@ -39,11 +39,14 @@ describe("HtmlSvgRenderer", () => {
expect(sandboxTokens).toContain("allow-popups-to-escape-sandbox");
expect(sandbox).not.toContain("allow-same-origin");
expect(sandbox).not.toContain("allow-top-navigation");
// srcdoc carries the assistant HTML plus a defense-in-depth meta CSP
// that adds ``connect-src 'none'`` and ``frame-src 'none'`` on top of
// the inherited host CSP. Inline ``<script>`` does NOT execute (the
// host CSP ``script-src 'self'`` strips it); the iframe is here to
// render layout/markup, not to run assistant JS.
// srcdoc carries the assistant HTML plus a defense-in-depth meta
// CSP (connect-src 'none', frame-src 'none', img-src data: blob:
// only). Inline <script> / on* handlers do NOT execute today
// because Chromium inherits the host CSP for srcdoc iframes
// (also for blob: and data: -- empirically reproduced) and the
// host enforces ``script-src 'self'``. The preview is for layout
// / styles / images / source viewing; interactive demos are a
// documented follow-up that needs a same-origin backend route.
expect(iframe.getAttribute("src")).toBeNull();
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc) ?? "";
expect(srcdoc).toContain("hello");
@ -125,6 +128,23 @@ describe("HtmlSvgRenderer", () => {
expect(previewTab.hasAttribute("disabled")).toBe(true);
});
it("srcdoc payload carries the defense-in-depth meta CSP", () => {
const html = "<button onclick=\"alert('x')\">go</button>";
render(<HtmlSvgRenderer language="html" source={html} />);
const iframe = screen.getByTestId(
"html-svg-renderer-iframe",
) as HTMLIFrameElement;
const doc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc) ?? "";
expect(doc).toContain("<button");
expect(doc).toContain('http-equiv="Content-Security-Policy"');
// Network egress is blocked regardless of script execution.
expect(doc).toContain("connect-src 'none'");
expect(doc).toContain("frame-src 'none'");
// ``<base target="_blank">`` keeps link clicks from replacing the
// iframe content with a navigation away from the preview.
expect(doc).toContain('<base target="_blank">');
});
it("wires tabs to their panels with aria-controls / aria-labelledby", () => {
const html = "<html><body>hi</body></html>";
render(<HtmlSvgRenderer language="html" source={html} />);

View file

@ -256,14 +256,17 @@ function SvgPreview({ source }: { source: string }) {
const HTML_PREVIEW_HEIGHT_REPORTER =
'<script>(()=>{const post=()=>parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");window.addEventListener("load",post);new ResizeObserver(post).observe(document.documentElement);})();</script>';
// Meta-CSP enforced INSIDE the srcdoc iframe. The iframe inherits the host
// Studio CSP (every srcdoc / data: / blob: scheme does, per CSP3 § Initialize
// document CSP), so the host's ``script-src 'self'`` already blocks inline
// <script> and on* handlers inside the preview. We layer a more restrictive
// meta-CSP here so a future host-CSP relaxation does not silently turn this
// iframe into an exfiltration channel: ``connect-src 'none'`` keeps a
// future ``script-src 'unsafe-inline'`` from being able to beacon out, and
// ``frame-src 'none'`` stops nested iframe-loaded ad/tracking content.
// Meta-CSP enforced INSIDE the srcdoc iframe. Chromium inherits the
// embedder CSP into srcdoc, data:, AND blob: iframes per HTML / CSP3
// § initialize-document-csp, so the host Studio ``script-src 'self'``
// already blocks assistant inline scripts and on* handlers here --
// confirmed empirically on the live Studio with a click-to-alert demo.
// Until a same-origin backend route is added (response-header CSPs
// do NOT inherit), the preview deliberately ships as a static-render
// surface. The meta-CSP below is defense in depth: it adds
// ``connect-src 'none'`` + ``frame-src 'none'`` so even if the host
// CSP ever loosens enough to let inline scripts run, the preview
// still cannot beacon out or nest tracking iframes.
const HTML_IFRAME_CSP = [
"default-src 'none'",
"script-src 'self' 'unsafe-inline'",
@ -283,8 +286,8 @@ function buildHtmlSrcDoc(source: string): string {
return [
"<!doctype html>",
`<meta http-equiv="Content-Security-Policy" content="${HTML_IFRAME_CSP}">`,
// Outbound links open in a new tab rather than no-op-navigating the
// sandboxed frame itself.
// Outbound links open in a new tab rather than navigating the
// sandboxed frame itself away from the preview.
'<base target="_blank">',
source,
HTML_PREVIEW_HEIGHT_REPORTER,
@ -301,19 +304,20 @@ function HtmlPreview({
onHeightChange?: (h: number | null) => void;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// srcdoc keeps the assistant HTML rendering same-origin-blocked while
// still showing layout, images, styles, and Streamdown-syntax-highlighted
// source in the Code tab. Inline <script> / on* handlers inside the
// assistant's HTML do NOT execute because srcdoc iframes inherit the
// host Studio CSP (``script-src 'self'``); follow-up work tracked in
// PR #5717 to add an opt-in backend-hosted preview route for the "play
// JS games inline" use case.
// srcdoc, blob:, and data: all inherit the host CSP in Chromium, so
// the choice between them does not affect script execution today.
// srcdoc is the simplest and avoids URL.createObjectURL churn, so
// that is what we use. Inline <script> / on* handlers in the
// assistant HTML do NOT execute under the current host CSP; the
// preview is for layout, images, and styles. The auto-height
// postMessage reporter is appended for the future state where the
// host CSP is relaxed via a backend-served preview route.
const srcDoc = useMemo(() => buildHtmlSrcDoc(source), [source]);
const [autoHeight, setAutoHeight] = useState<number | null>(null);
// Reset auto-sizing whenever the source changes so we never show the
// previous message's iframe size during the gap before the new doc loads
// and posts its first height.
// previous message's iframe size during the gap before the new doc
// loads and posts its first height.
useEffect(() => {
setAutoHeight(null);
onHeightChange?.(null);
@ -347,20 +351,16 @@ function HtmlPreview({
title="HTML preview"
srcDoc={srcDoc}
// SECURITY:
// allow-scripts -- in case the host CSP ever loosens
// to permit inline (today it does not)
// allow-modals -- so alert/confirm/prompt do not no-op
// when scripts do run
// allow-popups + -- so the ``<base target="_blank">`` we
// allow-popups-to- set above can open a new tab without
// escape-sandbox being silently dropped; escape-
// sandbox makes the popup a regular
// browser tab rather than an opaque-
// origin sandboxed one (the popup is
// equivalent to the user clicking the
// same URL in any other web app)
// We do NOT grant allow-same-origin or allow-top-navigation,
// so the iframe cannot read parent.document, navigate the host
// allow-scripts -- ready for the day the host CSP
// gives the preview a script-src
// that includes 'unsafe-inline'
// allow-modals -- alert/confirm/prompt are not no-ops
// when scripts do fire
// allow-popups + -- a ``<base target="_blank">`` link
// allow-popups-to- opens a regular browser tab rather
// escape-sandbox than an opaque-origin sandboxed one
// We do NOT grant allow-same-origin or allow-top-navigation, so
// the iframe cannot read parent.document, navigate the host
// page, or escape its origin.
sandbox="allow-scripts allow-modals allow-popups allow-popups-to-escape-sandbox"
style={{