Skip to content

fix(artifacts): the standalone artifact surfaces ran no JavaScript at all - #100

Merged
Broccolito merged 1 commit into
mainfrom
fix/artifact-wrapper-csp
Aug 22, 2026
Merged

fix(artifacts): the standalone artifact surfaces ran no JavaScript at all#100
Broccolito merged 1 commit into
mainfrom
fix/artifact-wrapper-csp

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

Every Auto Visualiser figure opened through "open in window", "open in browser",
or the headless renderer's blob: tab was an empty card — not a blank chart, an empty
card with no error message, because the figure's own error handler is a script too.

Found while verifying #99; deliberately left out of that PR because it is a
security-policy change on a different surface, and it affects every figure type rather
than Mermaid.

Root cause

A srcdoc document inherits its parent's CSP list and enforces both, so the guest's
effective policy is the intersection. ARTIFACT_WRAPPER_CSP
(artifactSecurity.ts:17) was hand-written as

default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'

which reads as a tight wrapper and is really a policy on the figure: default-src 'none' governed its scripts, images, fonts, workers and media. wrapArtifactForBrowser
is the only thing on those three surfaces, and it is used by openArtifactInWindow and
openArtifactInBrowser in main.ts and by the headless fallback in renderer.tsx. The
same constant is also served as a response header for file:// artifact URLs
(main.ts:4819), so both deliveries carried it.

Measured in the running dev app before the change, inside the opened window's guest
frame: window.mermaid and window.Chart both undefined, zero <svg>, an empty
<canvas>, zero [role=alert].

The in-chat artifact panel was unaffected because it hosts the figure's srcdoc inside
the renderer document, whose policy allows inline scripts — which is why this never
showed up there, and why mcp_ui_proxy.html (whose wrapper policy does carry
script-src 'unsafe-inline') works.

The fix

Derive the wrapper policy from the figure policy rather than writing it beside it,
overriding exactly one directive — frame-src, which the wrapper needs and a figure
does not.

  • ARTIFACT_BROWSER_CSP is unchanged, directive for directive (asserted against
    main while writing this), so the in-chat panel is untouched.
  • The wrapper gains 10 directives, and five of them are further denials it never
    stated: connect-src 'none', navigate-to 'none', form-action 'none',
    base-uri 'none', object-src 'none'. The five actual grants — script-src,
    img-src data: blob:, font-src data:, worker-src blob:, media-src data: blob:
    are exactly what the figure was already granted by its own policy.
  • Nothing about containment moves. The guest still carries the identical policy itself,
    and the sandbox attribute still withholds allow-same-origin and
    allow-top-navigation, so it cannot script the wrapper it sits in. The wrapper page
    contributes no script of its own; its only variable content is the escaped srcdoc.

Adding script-src alone is not the fix, and that is worth stating because it is the
obvious one-line patch: measured in Chromium, it restores scripts and leaves data:
images blocked, so a figure that embeds its own assets stays broken as what looks like an
unrelated bug. The browser test pins that case on its own.

Verified in the real app

Dev GUI on this branch, sandboxed with BIOROUTER_PATH_ROOT, driven over CDP; each
figure produced by the app's own daemon and opened with the real
window.electron.openArtifactWindow. Read from inside the opened window's guest frame:

Figure Before After
render_mermaid mermaid undefined, 0 svg, 0 alerts mermaid object, 1 svg, 0 alerts
show_chart Chart undefined, empty canvas Chart function, 1 canvas drawn
render_map (not run before) L object, 1 svg
render_mermaid in CDN mode, through prepareArtifactHtml mermaid object, 1 svg

Screenshots of the window surface show the diagram and the bar chart where there was
previously a bare title card.

Tests

  • artifactSecurity.test.ts — the drift guard that would have caught this: the wrapper
    must grant everything the figure is granted, and may differ only in frame-src. It
    fails on the pre-fix policy with 10 missing directives.
  • artifactSecurity.browser.test.ts — real Chromium, real wrapArtifactForBrowser, real
    figure: scripts run and the diagram is drawn; a data: image decodes; and a negative
    control replays the pre-fix wrapper policy and asserts that nothing ran, not even the
    error card. On the pre-fix policy the first two fail with expected 'undefined' to be 'object' and expected +0 to be 1.
  • npm run test:run — 3124 passed, 0 failed. npm run lint:check clean.

Rust is untouched, so cargo was not exercised beyond what #99 already covers.

Two things noted, not changed

  • mcp_ui_proxy.html has the same trap latent in it. Its wrapper policy carries
    script-src 'unsafe-inline' but also img-src 'none', font-src 'none',
    media-src 'none' and no worker-src — so a figure rendered through it would lose
    images and blob workers by inheritance. It is not reachable for figures in the desktop
    app today (onOpenArtifact is always supplied by BaseChat, so MCPUIResourceRenderer
    renders the open button and never mounts UIResourceRenderer), so there is no
    reproducible symptom to fix against and I have not guessed at one.
  • A map shows no tiles in any artifact surface, by design. img-src data: blob:
    names no remote scheme; markers and vector layers draw, tiles do not. Unchanged by this
    PR, and called out in the docs so it is not re-reported as a rendering bug.

… all

Every Auto Visualiser figure opened with "open in window", "open in
browser", or in the headless renderer's blob: tab was an empty card. Not
a blank chart — an empty card with no error message, because the
figure's own error handler is a script too.

A `srcdoc` document inherits its parent's CSP list and enforces *both*,
so the guest's effective policy is the intersection with the wrapper's.
ARTIFACT_WRAPPER_CSP was hand-written as

    default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'

which reads like a tight wrapper and is in fact a policy on the figure:
`default-src 'none'` governed its scripts, images, fonts, workers and
media. Measured in the running app before this change: `window.mermaid`
and `window.Chart` both `undefined`, no <svg>, no <canvas> content, no
error card.

Derive the wrapper policy from the figure policy instead of writing it
beside it, overriding one directive — `frame-src`, which the wrapper
needs and a figure does not. Ten directives are added to the wrapper and
five of them are further *denials* (connect-src, navigate-to,
form-action, base-uri, object-src) that it did not previously state.
ARTIFACT_BROWSER_CSP is unchanged, directive for directive, so the
in-chat panel is untouched.

Adding `script-src` alone would not have been the fix: measured in
Chromium, that restores scripts and leaves `data:` images blocked, so a
figure embedding its own assets stays broken as what looks like an
unrelated bug. The browser test pins that case separately.

Containment is unchanged and does not come from the wrapper policy: the
guest carries the same policy itself, and the sandbox attribute still
withholds allow-same-origin and allow-top-navigation, so it cannot
script the wrapper it is inside.
@Broccolito
Broccolito merged commit 14350cd into main Aug 22, 2026
13 checks passed
@Broccolito
Broccolito deleted the fix/artifact-wrapper-csp branch August 22, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant