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:
Daniel Han 2026-05-24 16:05:11 +00:00
commit 42ff23de73
7 changed files with 342 additions and 35 deletions

View file

@ -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