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:
parent
fc28f8486b
commit
115ff8978c
4 changed files with 73 additions and 45 deletions
|
|
@ -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'; "
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue