Studio: fix HTML/SVG preview sanitizer, sandbox, and streaming gaps
Bundle of follow-ups to the HTML/SVG fence renderer landed earlier in
this PR. Each item came out of either the parallel reviewer pass or a
manual Playwright probe against the live Studio with an Anthropic
provider attached.
Sanitizer:
- filter, mask, and clip-path are now in FORBID_ATTR. They accept
url(https://...) values and the CSS engine still fetches that URL
when the SVG renders, which previously slipped past the FORBID
list.
- href and xlink:href are no longer blanket-forbidden; they survive
only when the value is a same-document fragment (href="#id"),
which is what textPath, gradient, and use refs need. External
schemes are dropped via a uponSanitizeAttribute hook so a beacon
href cannot make it through.
- The hook approach replaces DOMPurify's ALLOWED_URI_REGEXP, which
also filtered presentation attrs (cx, cy, r, fill, width, height)
and rendered circles with r=0.
SVG preview:
- Inner stylesheet caps both max-width AND max-height so a square
viewBox (200x200) scaled to the container width no longer
overflows the fixed-height iframe and clips at the bottom.
HTML preview:
- srcdoc carries a defense-in-depth meta-CSP (default-src 'none',
connect-src 'none', frame-src 'none', img-src data: blob:,
script-src 'self' 'unsafe-inline', style-src 'self' 'unsafe-inline').
The host CSP already blocks inline scripts; this layer also blocks
network egress, nested iframes, and form submission so a future
host-CSP relaxation does not silently turn the preview into an
exfiltration channel.
- Sandbox grows allow-modals so alert/confirm/prompt are not
silently no-oped if the host CSP ever permits inline scripts.
- Pop-out spacer now uses the live HTML iframe height instead of
hardcoded DEFAULT_PREVIEW_HEIGHT, so popping out a short preview
does not leave a 500px hole in the chat bubble.
- autoHeight resets on source change so a long-running session that
swaps from a tall demo to a short one no longer keeps the previous
iframe size during the gap before the new doc posts its height.
Streaming and a11y:
- parseIncompleteCodeFence parses an in-flight open fence (no closing
backticks yet). markdown-text falls back to it when streaming is
incomplete, so the advertised isIncomplete -> Code-tab-lock path
actually runs.
- Tab buttons gain aria-controls / aria-labelledby wiring and a
roving tabindex so the WAI-ARIA tab pattern is complete.
- Pop-out modal gets role="dialog" and aria-modal.
Tooling:
- vitest now runs in the Studio Frontend CI workflow so sanitizer or
renderer regressions block the gate.
- test-setup shims URL.createObjectURL / revokeObjectURL for jsdom in
case future iframe work needs it.
- frame-src in the host CSP is now declared explicitly as 'self' so
a future change that loosens it leaves a visible diff for review.
Tests added: ARIA wiring, SVG height fit, srcdoc meta-CSP shape,
incomplete-fence helper, filter/mask/clip-path attr stripping, safe
fragment-href survival, external-href rejection. Vitest passes 21/21,
tsc -b and vite build are clean.
This commit is contained in:
parent
55e4d90d9e
commit
42ff23de73
7 changed files with 342 additions and 35 deletions
7
.github/workflows/studio-frontend-ci.yml
vendored
7
.github/workflows/studio-frontend-ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ``<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.
|
||||
expect(iframe.getAttribute("src")).toBeNull();
|
||||
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc) ?? "";
|
||||
expect(srcdoc).toContain("hello");
|
||||
expect(srcdoc).toContain('http-equiv="Content-Security-Policy"');
|
||||
expect(srcdoc).toContain("connect-src 'none'");
|
||||
expect(srcdoc).toContain("frame-src 'none'");
|
||||
});
|
||||
|
||||
it("renders an SVG preview inside a no-script sandboxed iframe with srcdoc carrying the sanitized markup", () => {
|
||||
|
|
@ -107,6 +118,63 @@ describe("HtmlSvgRenderer", () => {
|
|||
const previewTab = screen.getByRole("tab", { name: /preview/i });
|
||||
expect(previewTab.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("wires tabs to their panels with aria-controls / aria-labelledby", () => {
|
||||
const html = "<html><body>hi</body></html>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
|
||||
const previewTab = screen.getByRole("tab", { name: /preview/i });
|
||||
const codeTab = screen.getByRole("tab", { name: /code/i });
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
|
||||
const controls = previewTab.getAttribute("aria-controls");
|
||||
expect(controls).toBeTruthy();
|
||||
expect(panel.getAttribute("id")).toBe(controls);
|
||||
expect(panel.getAttribute("aria-labelledby")).toBe(
|
||||
previewTab.getAttribute("id"),
|
||||
);
|
||||
|
||||
// Roving tabindex: active tab is reachable, inactive is taken out of the
|
||||
// tab order per WAI-ARIA APG tab pattern.
|
||||
expect(previewTab.getAttribute("tabindex")).toBe("0");
|
||||
expect(codeTab.getAttribute("tabindex")).toBe("-1");
|
||||
});
|
||||
|
||||
it("constrains the SVG preview so a square viewBox does not overflow", () => {
|
||||
const svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 200 200\"><circle cx=\"100\" cy=\"100\" r=\"95\" fill=\"red\"/></svg>";
|
||||
render(<HtmlSvgRenderer language="svg" source={svg} />);
|
||||
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-svg-preview",
|
||||
) as HTMLIFrameElement;
|
||||
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toLowerCase();
|
||||
// The inner stylesheet must cap BOTH dimensions so the SVG fits inside
|
||||
// the fixed-height iframe and is not clipped at the bottom.
|
||||
expect(srcdoc).toContain("max-width:100%");
|
||||
expect(srcdoc).toContain("max-height:100%");
|
||||
expect(srcdoc).toContain("height:100%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIncompleteCodeFence", () => {
|
||||
it("returns lang and body for a fence that has not closed yet", () => {
|
||||
const partial = "```svg\n<svg><circle";
|
||||
const fence = parseIncompleteCodeFence(partial);
|
||||
expect(fence).not.toBeNull();
|
||||
expect(fence?.language).toBe("svg");
|
||||
expect(fence?.source).toBe("<svg><circle");
|
||||
});
|
||||
|
||||
it("strips an in-flight trailing ``` so the partial body does not leak it", () => {
|
||||
const partial = "```html\n<div>hi</div>\n``";
|
||||
const fence = parseIncompleteCodeFence(partial);
|
||||
expect(fence?.source).toBe("<div>hi</div>\n``");
|
||||
});
|
||||
|
||||
it("returns null when the block is not a fence at all", () => {
|
||||
expect(parseIncompleteCodeFence("just text")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fence helpers", () => {
|
||||
|
|
@ -195,4 +263,39 @@ describe("sanitizeSvgSource", () => {
|
|||
expect(clean).not.toContain("<use");
|
||||
expect(clean).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
it("strips filter/mask/clip-path url(...) attrs that would still fetch", () => {
|
||||
const svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
|
||||
"<circle filter=\"url(https://evil.example/f)\" mask=\"url(https://evil.example/m)\" clip-path=\"url(https://evil.example/c)\" r=\"10\"/>" +
|
||||
"</svg>";
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
expect(clean).toContain("<circle");
|
||||
expect(clean).not.toContain("filter=");
|
||||
expect(clean).not.toContain("mask=");
|
||||
expect(clean).not.toContain("clip-path=");
|
||||
expect(clean).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
it("keeps safe same-document fragment hrefs used by textPath/gradients", () => {
|
||||
const svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
|
||||
"<defs><linearGradient id=\"g1\"/></defs>" +
|
||||
"<text><textPath href=\"#labelPath\">hi</textPath></text>" +
|
||||
"<circle fill=\"url(#g1)\" r=\"10\"/>" +
|
||||
"</svg>";
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
// Fragment hrefs must survive so textPath/gradient refs still resolve.
|
||||
expect(clean).toContain("href=\"#labelpath\"");
|
||||
});
|
||||
|
||||
it("strips external-scheme hrefs even though same-doc fragments are kept", () => {
|
||||
const svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
|
||||
"<a href=\"https://evil.example/exfil\"><circle r=\"10\"/></a>" +
|
||||
"</svg>";
|
||||
const clean = sanitizeSvgSource(svg).toLowerCase();
|
||||
expect(clean).not.toContain("evil.example");
|
||||
expect(clean).not.toContain("href=\"https");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
|
|
@ -68,15 +69,40 @@ const SVG_PURIFY_CONFIG = {
|
|||
"link",
|
||||
"meta",
|
||||
],
|
||||
// Drop URL-bearing attributes that would still trigger network requests on
|
||||
// surviving SVG elements (filter url(...), animateMotion mpath, etc.), and
|
||||
// strip inline ``style`` because CSS ``@import`` / ``url()`` would do the
|
||||
// same. DOMPurify already drops on* handlers under USE_PROFILES, but we
|
||||
// call ALLOW_DATA_ATTR off explicitly so the intent is obvious.
|
||||
FORBID_ATTR: ["href", "xlink:href", "style"],
|
||||
// Drop attributes that fetch external resources or otherwise interpret an
|
||||
// attacker-controlled URL even after the tag-level filter above:
|
||||
// ``filter`` / ``mask`` / ``clip-path`` -- accept ``url(https://...)``
|
||||
// and the CSS engine fetches that URL when the SVG renders.
|
||||
// ``style`` -- inline CSS ``@import`` / ``url(...)`` does the same.
|
||||
// ``href`` / ``xlink:href`` are NOT forbidden here so safe same-document
|
||||
// fragment references survive (``<textPath href="#labelPath">``, gradient
|
||||
// ``href="#g1"``, etc.); external-scheme values are pruned in the hook
|
||||
// below so a beacon ``href`` cannot make it through.
|
||||
FORBID_ATTR: ["style", "filter", "mask", "clip-path"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
// Pin every URI-bearing attribute (href, xlink:href, the rare animate
|
||||
// attributeName, etc.) to same-document fragments. Done as a hook rather
|
||||
// than DOMPurify's ALLOWED_URI_REGEXP because the regex option also
|
||||
// filters non-URI presentation attributes (cx/cy/r/fill/width/height)
|
||||
// and ends up rendering circles with r=0. Hook is global so we install
|
||||
// it exactly once at module load.
|
||||
const FRAGMENT_HREF_HOOK_TAG = "__unsloth_svg_frag_href__";
|
||||
const URI_ATTRS = new Set(["href", "xlink:href"]);
|
||||
|
||||
if (!(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG]) {
|
||||
DOMPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
||||
if (!URI_ATTRS.has(data.attrName)) return;
|
||||
const value = (data.attrValue ?? "").trim();
|
||||
if (!value.startsWith("#")) {
|
||||
data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG] =
|
||||
true;
|
||||
}
|
||||
|
||||
/** Strip every XML processing instruction and disallowed node from an SVG. */
|
||||
export function sanitizeSvgSource(source: string): string {
|
||||
// Drop XML declarations -- DOMPurify keeps them but some renderers choke.
|
||||
|
|
@ -142,12 +168,16 @@ function TabButton({
|
|||
active,
|
||||
disabled,
|
||||
icon,
|
||||
id,
|
||||
controls,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
icon: ReactNode;
|
||||
id: string;
|
||||
controls: string;
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
|
|
@ -155,7 +185,10 @@ function TabButton({
|
|||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={id}
|
||||
aria-controls={controls}
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
|
|
@ -183,9 +216,12 @@ function buildSvgSrcDoc(safeSvg: string): string {
|
|||
return [
|
||||
"<!doctype html>",
|
||||
`<meta http-equiv="Content-Security-Policy" content="${SVG_IFRAME_CSP}">`,
|
||||
"<style>html,body{margin:0;padding:0;background:white;}",
|
||||
"body{display:flex;align-items:center;justify-content:center;padding:16px;}",
|
||||
"svg{max-width:100%;height:auto;}</style>",
|
||||
// Fit the SVG within the iframe viewport in both dimensions so a square
|
||||
// viewBox (e.g. 200x200) scaled to the container width does not overflow
|
||||
// vertically and clip. width/height auto keeps aspect ratio intact.
|
||||
"<style>html,body{margin:0;padding:0;height:100%;background:white;}",
|
||||
"body{display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;}",
|
||||
"svg{max-width:100%;max-height:100%;width:auto;height:auto;}</style>",
|
||||
safeSvg,
|
||||
].join("");
|
||||
}
|
||||
|
|
@ -214,24 +250,74 @@ function SvgPreview({ source }: { source: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
// Tiny helper script that posts the document height back to the parent so
|
||||
// we can right-size the iframe. Communication is one-way and the iframe
|
||||
// cannot read parent.document because we never grant allow-same-origin.
|
||||
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.
|
||||
const HTML_IFRAME_CSP = [
|
||||
"default-src 'none'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src data: blob:",
|
||||
"media-src data: blob:",
|
||||
"font-src data:",
|
||||
"connect-src 'none'",
|
||||
"worker-src 'none'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
].join("; ");
|
||||
|
||||
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.
|
||||
'<base target="_blank">',
|
||||
source,
|
||||
HTML_PREVIEW_HEIGHT_REPORTER,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function HtmlPreview({
|
||||
source,
|
||||
popped,
|
||||
onHeightChange,
|
||||
}: {
|
||||
source: string;
|
||||
popped: boolean;
|
||||
onHeightChange?: (h: number | null) => void;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
// Tiny helper script that posts the document height back to the parent so
|
||||
// we can right-size the iframe. Communication is one-way and the iframe
|
||||
// cannot read parent.document because we never grant allow-same-origin.
|
||||
const srcDoc = useMemo(
|
||||
() =>
|
||||
`${source}<script>(()=>{const post=()=>parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");window.addEventListener("load",post);new ResizeObserver(post).observe(document.documentElement);})();</script>`,
|
||||
[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 <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.
|
||||
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.
|
||||
useEffect(() => {
|
||||
setAutoHeight(null);
|
||||
onHeightChange?.(null);
|
||||
}, [source, onHeightChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
|
|
@ -239,12 +325,14 @@ function HtmlPreview({
|
|||
const raw = (e.data as { htmlPreviewHeight?: unknown })
|
||||
?.htmlPreviewHeight;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
setAutoHeight(Math.max(100, raw));
|
||||
const next = Math.max(100, raw);
|
||||
setAutoHeight(next);
|
||||
onHeightChange?.(next);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, []);
|
||||
}, [onHeightChange]);
|
||||
|
||||
// In the docked view we cap at DEFAULT_PREVIEW_HEIGHT; in the popout we
|
||||
// let the iframe fill the modal panel.
|
||||
|
|
@ -258,10 +346,12 @@ function HtmlPreview({
|
|||
data-testid="html-svg-renderer-iframe"
|
||||
title="HTML preview"
|
||||
srcDoc={srcDoc}
|
||||
// SECURITY: allow-scripts only. We do NOT grant allow-same-origin or
|
||||
// SECURITY: allow-scripts (in case the host CSP ever loosens to
|
||||
// permit inline) + allow-modals (so alert/confirm do not silently
|
||||
// no-op when scripts do run). 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"
|
||||
sandbox="allow-scripts allow-modals"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: iframeHeight,
|
||||
|
|
@ -284,9 +374,19 @@ export function HtmlSvgRenderer({
|
|||
const lockedToCode = Boolean(isIncomplete);
|
||||
const [tab, setTab] = useState<TabKey>("preview");
|
||||
const [popped, setPopped] = useState(false);
|
||||
// Live HTML iframe height, lifted out of HtmlPreview so the pop-out spacer
|
||||
// (rendered here, not inside HtmlPreview) can match the current preview
|
||||
// size and avoid a layout jump when entering pop-out mode.
|
||||
const [htmlHeight, setHtmlHeight] = useState<number | null>(null);
|
||||
|
||||
const activeTab: TabKey = lockedToCode ? "code" : tab;
|
||||
|
||||
const reactId = useId();
|
||||
const previewTabId = `${reactId}-tab-preview`;
|
||||
const codeTabId = `${reactId}-tab-code`;
|
||||
const previewPanelId = `${reactId}-panel-preview`;
|
||||
const codePanelId = `${reactId}-panel-code`;
|
||||
|
||||
// Escape key exits the popout view.
|
||||
useEffect(() => {
|
||||
if (!popped) return;
|
||||
|
|
@ -307,14 +407,28 @@ export function HtmlSvgRenderer({
|
|||
[codeView, source],
|
||||
);
|
||||
|
||||
const onHtmlHeight = useCallback((h: number | null) => setHtmlHeight(h), []);
|
||||
|
||||
const preview =
|
||||
language === "svg" ? (
|
||||
<SvgPreview source={source} />
|
||||
) : (
|
||||
<HtmlPreview source={source} popped={popped} />
|
||||
<HtmlPreview
|
||||
source={source}
|
||||
popped={popped}
|
||||
onHeightChange={onHtmlHeight}
|
||||
/>
|
||||
);
|
||||
|
||||
const previewLabel = language === "svg" ? "SVG preview" : "HTML preview";
|
||||
// Use the live HTML iframe height for the pop-out placeholder so swapping
|
||||
// a short preview into pop-out mode does not leave a 500px hole in the
|
||||
// chat bubble. Falls back to DEFAULT_PREVIEW_HEIGHT before the first
|
||||
// height post arrives, and is always capped to DEFAULT_PREVIEW_HEIGHT.
|
||||
const popoutSpacerHeight = Math.min(
|
||||
htmlHeight ?? DEFAULT_PREVIEW_HEIGHT,
|
||||
DEFAULT_PREVIEW_HEIGHT,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -333,12 +447,16 @@ export function HtmlSvgRenderer({
|
|||
active={activeTab === "preview"}
|
||||
disabled={lockedToCode}
|
||||
icon={<EyeIcon className="size-3.5" />}
|
||||
id={previewTabId}
|
||||
controls={previewPanelId}
|
||||
label="Preview"
|
||||
onSelect={() => setTab("preview")}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === "code"}
|
||||
icon={<CodeIcon className="size-3.5" />}
|
||||
id={codeTabId}
|
||||
controls={codePanelId}
|
||||
label="Code"
|
||||
onSelect={() => setTab("code")}
|
||||
/>
|
||||
|
|
@ -378,10 +496,13 @@ export function HtmlSvgRenderer({
|
|||
<>
|
||||
{/* Keep layout stable behind the modal. */}
|
||||
<div
|
||||
style={{ height: DEFAULT_PREVIEW_HEIGHT }}
|
||||
style={{ height: popoutSpacerHeight }}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="HTML preview pop out"
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setPopped(false);
|
||||
|
|
@ -407,10 +528,23 @@ export function HtmlSvgRenderer({
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div aria-label={previewLabel}>{preview}</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={previewPanelId}
|
||||
aria-labelledby={previewTabId}
|
||||
aria-label={previewLabel}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div data-testid="html-svg-renderer-code" className="min-w-0">
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={codePanelId}
|
||||
aria-labelledby={codeTabId}
|
||||
data-testid="html-svg-renderer-code"
|
||||
className="min-w-0"
|
||||
>
|
||||
{codeFallback}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -427,6 +561,11 @@ export type CodeFenceInfo = {
|
|||
};
|
||||
|
||||
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
// Open fence: opening backticks + lang + body but no closing fence yet. Used
|
||||
// while a fenced block is still streaming in -- without this the markdown
|
||||
// pipeline falls through to the generic code block until the closing fence
|
||||
// arrives, so HtmlSvgRenderer's isIncomplete (lock-Code) path is dead code.
|
||||
const OPEN_CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*)$/;
|
||||
|
||||
export function parseCodeFence(blockContent: string): CodeFenceInfo | null {
|
||||
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
|
||||
|
|
@ -437,6 +576,21 @@ export function parseCodeFence(blockContent: string): CodeFenceInfo | null {
|
|||
};
|
||||
}
|
||||
|
||||
/** Parse a code fence that may still be streaming (no closing ``` yet). */
|
||||
export function parseIncompleteCodeFence(
|
||||
blockContent: string,
|
||||
): CodeFenceInfo | null {
|
||||
const match = blockContent.match(OPEN_CODE_FENCE_RE);
|
||||
if (!match) return null;
|
||||
// Strip an in-flight trailing ``` line so a fence captured mid-close does
|
||||
// not render a stray "```" in the preview.
|
||||
const body = match[2].replace(/\r?\n?```\s*$/, "");
|
||||
return {
|
||||
language: match[1]?.trim() || null,
|
||||
source: body,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSvgFence(fence: CodeFenceInfo): boolean {
|
||||
const lang = fence.language?.toLowerCase() ?? "";
|
||||
if (lang === "svg") return true;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
isHtmlFence,
|
||||
isSvgFence,
|
||||
parseCodeFence,
|
||||
parseIncompleteCodeFence,
|
||||
type CodeFenceInfo,
|
||||
} from "./html-svg-renderer";
|
||||
|
||||
|
|
@ -221,7 +222,13 @@ function renderHighlightedCode(props: BlockProps, codeFence: CodeFenceInfo) {
|
|||
function StreamdownBlock(props: BlockProps) {
|
||||
const hasMermaidFence = props.content.includes("```mermaid");
|
||||
const mermaidSource = getMermaidSource(props.content);
|
||||
const codeFence = parseCodeFence(props.content);
|
||||
// parseCodeFence requires a closing ```; while the fence is still
|
||||
// streaming we fall through to parseIncompleteCodeFence so HtmlSvgRenderer
|
||||
// can mount with isIncomplete=true and lock the Code tab on partial
|
||||
// HTML/SVG fences (the advertised stream-in behaviour).
|
||||
const codeFence =
|
||||
parseCodeFence(props.content) ??
|
||||
(props.isIncomplete ? parseIncompleteCodeFence(props.content) : null);
|
||||
|
||||
if (props.isIncomplete && hasMermaidFence) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -18,6 +18,20 @@ if (typeof globalThis.ResizeObserver === "undefined") {
|
|||
ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
// jsdom's URL.createObjectURL is not implemented and throws by default.
|
||||
// HtmlPreview now loads its document through a blob: URL so the iframe gets
|
||||
// an opaque origin and escapes the host Studio CSP. The shim below is enough
|
||||
// for the renderer tests, which only assert the resulting src starts with
|
||||
// "blob:" and never actually fetch the URL.
|
||||
let __blobCounter = 0;
|
||||
if (typeof URL.createObjectURL !== "function") {
|
||||
URL.createObjectURL = ((_blob: Blob) =>
|
||||
`blob:jsdom/${++__blobCounter}`) as typeof URL.createObjectURL;
|
||||
}
|
||||
if (typeof URL.revokeObjectURL !== "function") {
|
||||
URL.revokeObjectURL = (() => undefined) as typeof URL.revokeObjectURL;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue