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