diff --git a/.github/workflows/studio-frontend-ci.yml b/.github/workflows/studio-frontend-ci.yml index 1270a57ef6..f22b5909c8 100644 --- a/.github/workflows/studio-frontend-ci.yml +++ b/.github/workflows/studio-frontend-ci.yml @@ -109,6 +109,13 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Frontend unit tests (vitest) + # New vitest suite covers the HtmlSvgRenderer iframe sandbox / + # CSP / sanitizer contract. Run it before the build so a + # sanitizer regression fails the gate even if the bundle still + # builds clean. + run: npm run test + - name: Build run: npm run build diff --git a/studio/backend/main.py b/studio/backend/main.py index 004ae404cd..19202efcb1 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -327,6 +327,10 @@ def _build_csp(script_nonce: "str | None" = None) -> str: "style-src 'self' 'unsafe-inline'; " 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. + "frame-src 'self'; " "frame-ancestors 'none'; " "form-action 'self'; " "base-uri 'self'" diff --git a/studio/backend/tests/test_middleware.py b/studio/backend/tests/test_middleware.py index bbaf20298d..f2a216b9e5 100644 --- a/studio/backend/tests/test_middleware.py +++ b/studio/backend/tests/test_middleware.py @@ -196,6 +196,24 @@ class TestSecurityHeadersMiddleware: nonced = main_module._build_csp("XYZ") 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. + csp = main_module._build_csp() + frame_src = next( + chunk.strip() + for chunk in csp.split(";") + if chunk.strip().startswith("frame-src ") + ) + tokens = frame_src.split() + assert tokens[0] == "frame-src" + assert "'self'" in tokens + assert "data:" not in tokens + assert "blob:" not in tokens + def test_img_src_allows_google_favicons(self, main_module): # sources.tsx fetches https://www.google.com/s2/favicons?... ; without # this allowlist entry citation favicons fall back to gray initials. diff --git a/studio/frontend/src/components/assistant-ui/__tests__/html-svg-renderer.test.tsx b/studio/frontend/src/components/assistant-ui/__tests__/html-svg-renderer.test.tsx index 1b64114575..4de79e7b7c 100644 --- a/studio/frontend/src/components/assistant-ui/__tests__/html-svg-renderer.test.tsx +++ b/studio/frontend/src/components/assistant-ui/__tests__/html-svg-renderer.test.tsx @@ -8,6 +8,7 @@ import { isHtmlFence, isSvgFence, parseCodeFence, + parseIncompleteCodeFence, sanitizeSvgSource, } from "../html-svg-renderer"; @@ -24,15 +25,25 @@ describe("HtmlSvgRenderer", () => { "html-svg-renderer-iframe", ) as HTMLIFrameElement; expect(iframe.tagName).toBe("IFRAME"); - // SECURITY: allow-scripts only; never allow-same-origin or - // allow-top-navigation. If this ever changes, the preview can read - // parent.document and exfiltrate session data. - expect(iframe.getAttribute("sandbox")).toBe("allow-scripts"); - expect(iframe.getAttribute("sandbox")).not.toContain("allow-same-origin"); - expect(iframe.getAttribute("sandbox")).not.toContain( - "allow-top-navigation", - ); - expect(iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toContain("hello"); + // SECURITY: allow-scripts + allow-modals leave script / alert / + // confirm operative IF the inherited host CSP ever permits inline; + // NEVER allow-same-origin or allow-top-navigation -- those would let + // the preview read parent.document and exfiltrate session data. + const sandbox = iframe.getAttribute("sandbox") ?? ""; + expect(sandbox.split(/\s+/)).toContain("allow-scripts"); + 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 ``'; + +// 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 +// `, - [source], - ); + // 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