Studio: trim PR 5717 in-code comments to one short sentence each

Drops 234 lines of inline commentary across the renderer, sanitizer,
HTML preview route, supply-chain gate, and their tests. Same intent,
shorter form. No code behaviour changes; vitest 24/24, pytest 24/24,
tsc / vite build clean.
This commit is contained in:
Daniel Han 2026-05-27 07:42:00 +00:00
commit 169e5b3e96
12 changed files with 195 additions and 429 deletions

View file

@ -1103,13 +1103,9 @@ jobs:
BASE_SHA="${{ github.event.pull_request.base.sha }}"
git show "$BASE_SHA:studio/frontend/package-lock.json" \
> /tmp/base-package-lock.json
# Pull the TRUSTED allowlist from the base ref so a PR cannot
# allowlist its own new postinstall dependency in the same diff
# the checker scans. If the file does NOT exist on base, REMOVE
# the temp file -- the checker treats a missing base allowlist
# as bootstrap mode (the PR that introduces the file is allowed
# to populate it; once it lands, subsequent PRs must respect
# the head-only rejection rule).
# Pull TRUSTED allowlist from base so a PR cannot self-approve
# a new postinstall in the same diff. Missing-on-base = bootstrap
# mode (gate accepts head allowlist for THAT PR only).
if ! git show "$BASE_SHA:studio/frontend/.install-script-allowlist" \
> /tmp/base-install-script-allowlist 2>/dev/null; then
rm -f /tmp/base-install-script-allowlist

View file

@ -110,10 +110,8 @@ jobs:
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.
# Covers HtmlSvgRenderer sandbox / CSP / sanitizer. Runs before
# build so a regression fails the gate even if the bundle is clean.
run: npm run test
- name: Build

View file

@ -4,32 +4,21 @@
"""Diff two `package-lock.json` files and flag NEW install-script deps.
A package with `"hasInstallScript": true` runs `preinstall` / `install` /
`postinstall` lifecycle hooks every time `npm ci` lays it down. Every
npm supply-chain compromise of the last 18 months (Shai-Hulud,
TanStack, axios-style, ArmorCode hijacks) leveraged exactly this lever:
the attacker publishes a new malicious version of a dep we already
trust, and the post-install hook runs the next time CI installs.
Packages with `hasInstallScript: true` run preinstall/install/postinstall
on every `npm ci`. Every npm supply-chain compromise of the last 18 months
(Shai-Hulud, TanStack, axios, ArmorCode) used this lever: malicious
version of a trusted dep, hook runs on next install.
This scanner refuses to allow a newly-introduced install-script dep to
land without a maintainer eyeball on the lifecycle script body.
Existing install-script deps are NOT re-flagged -- if `node-gyp` has
been in the lockfile since day one, it's not part of this PR's threat
model. Only new entries are surfaced.
Existing install-script deps are NOT re-flagged; only NEW entries surface.
Supports lockfileVersion 1 (`dependencies` key, recursive), 2 and 3
(flat `packages` key with `node_modules/<a>/node_modules/<b>` nesting
for transitive entries). For each NEW install-script package we
attempt a stdlib-only fetch of
`https://registry.npmjs.org/<name>/<version>` to recover the actual
postinstall command body. If the network is blocked we still emit the
finding -- the lifecycle command body is informational, not
load-bearing.
Supports lockfileVersion 1 (`dependencies`, recursive) and 2/3 (flat
`packages` with `node_modules/<a>/node_modules/<b>` nesting). For each
finding we try a stdlib fetch of `https://registry.npmjs.org/<name>/<version>`
to recover the lifecycle body; network failure is non-fatal.
Exit codes
==========
Exit codes:
0 no newly-added install-script deps
1 one or more newly-added install-script deps; listed on stderr
1 newly-added install-script deps listed on stderr
2 internal error (missing lockfile, malformed JSON, etc.)
"""
@ -76,15 +65,11 @@ class Finding:
def _strip_nm_prefix(key: str) -> str:
"""Convert a v2/v3 `packages` key into a bare package name.
`node_modules/foo` -> `foo`; `node_modules/foo/node_modules/bar` ->
`bar`. The empty key (`""`) is the project root and returns "".
"""
"""v2/v3 `packages` key -> bare leaf name. ``""`` is the project root."""
if not key:
return ""
# Use the LAST `node_modules/` segment so transitives map to their
# leaf name, matching how npm install resolves a postinstall.
# Last `node_modules/` segment: transitives map to leaf name (matches
# how npm resolves the postinstall).
marker = "node_modules/"
idx = key.rfind(marker)
if idx == -1:
@ -97,10 +82,8 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
every entry with `hasInstallScript: true` (v2/v3) OR a
non-empty `scripts.preinstall|install|postinstall` (v1).
The same package may appear at multiple versions in a single
lockfile (de-duplicated copies under different parents); we key by
`name@version` so we don't lose either copy. Returns a dict keyed
by `name@version` -> the same string for convenience.
Keyed by `name@version` so duplicate copies at different versions
are not collapsed. Returns {`name@version`: name}.
"""
seen: dict[str, str] = {}
version = lock.get("lockfileVersion")
@ -120,10 +103,8 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name
# v1 also embeds a `dependencies` tree; v2/v3 carry both for
# backwards-compat but `packages` is canonical for them. For v1
# there is no `hasInstallScript` flag, so look for a non-empty
# `scripts.preinstall|install|postinstall` directly.
# v1 has no `hasInstallScript` flag -- detect via non-empty
# scripts.{preinstall,install,postinstall}.
def _walk_v1(deps: dict, depth: int = 0) -> None:
if depth > 64 or not isinstance(deps, dict):
return
@ -135,8 +116,6 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
isinstance(scripts, dict) and scripts.get(hook)
for hook in ("preinstall", "install", "postinstall")
)
# v1 also sets `requires` only on the parent, no flag, so
# the lifecycle-script presence is the only signal.
if lifecycle:
ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name
@ -163,12 +142,9 @@ def _load_lockfile(path: Path) -> dict:
def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None:
"""Return {hook: command} for any of preinstall / install /
postinstall published in the registry metadata for this name@ver.
"""Return {hook: command} from registry metadata, or None on any failure.
Returns None on any error (network blocked, 404, malformed JSON).
Never raises; the caller treats absence as "could not enrich, emit
finding anyway".
Never raises; absence means "emit finding without enrichment".
"""
safe_name = urllib.parse.quote(name, safe = "@/")
url = f"{REGISTRY_BASE}{safe_name}/{urllib.parse.quote(version)}"
@ -203,9 +179,8 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
findings: list[Finding] = []
for key in sorted(head):
if key in base:
continue # pre-existing install-script dep; not in scope
continue # pre-existing dep, out of scope
name = head[key]
# key is "name@version"; rsplit("@", 1) handles scoped names.
version = (
key[len(name) + 1 :] if key.startswith(name + "@") else "<unversioned>"
)
@ -215,8 +190,7 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
else:
detail = (
"newly added with hasInstallScript=true; registry "
"metadata unreachable -- inspect the package's "
"scripts.{preinstall,install,postinstall} manually"
"unreachable -- inspect scripts.{preinstall,install,postinstall}"
)
findings.append(
Finding(
@ -236,14 +210,12 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
def _load_allowlist(path: Path) -> set[str]:
"""Read a file of newline-separated ``name@version`` entries to skip.
"""Newline-separated `name@version` entries to skip.
Each entry MUST be pinned to an exact version (``esbuild@0.21.5``,
``@scope/pkg@1.2.3``). Bare names are rejected so allowlisting
``esbuild`` cannot silently approve a later malicious
``esbuild@99.0.0`` published by a compromised maintainer -- every
new version requires its own review. Lines starting with ``#`` are
comments; blank lines are ignored. Missing file = empty allowlist.
Each entry MUST be pinned to an exact version; bare names rejected so
a compromised maintainer cannot ship a malicious later version under
an existing allowlist line. `#` comments and blank lines ignored.
Missing file = empty set.
"""
if not path.exists():
return set()
@ -301,9 +273,8 @@ def main(argv: list[str] | None = None) -> int:
default = None,
help = (
"Path to the TRUSTED BASE allowlist. Defaults to "
"'<base dir>/.install-script-allowlist'. Entries that exist "
"only on HEAD fail the gate so a PR cannot allowlist its "
"own new postinstall dependency."
"'<base dir>/.install-script-allowlist'. Head-only entries that "
"self-approve a new postinstall fail the gate."
),
)
args = parser.parse_args(argv)
@ -335,13 +306,9 @@ def main(argv: list[str] | None = None) -> int:
raw_findings = diff_new_install_scripts(base_lock, head_lock)
raw_findings_keys = {_finding_allowlist_key(f) for f in raw_findings}
# Bootstrap: if the BASE ref has no allowlist file at all, this is
# the PR that creates it. There is no prior allowlist to diff
# against, and refusing every head entry here would make the gate
# unlandable. The workflow signals "missing on base" by NOT writing
# the temp file (rm -f after a failed ``git show``), which is what
# we detect here. Once the file exists on base, future PRs must
# respect the head-only / deletion rules below.
# Bootstrap: PR that creates the file. Workflow signals "missing on base"
# by `rm -f` after a failed `git show`. After landing, head-only / delete
# rules below kick in.
if not base_allowlist_path.exists():
print(
f"[install-script-diff] bootstrap: {base_allowlist_path} "
@ -357,13 +324,9 @@ def main(argv: list[str] | None = None) -> int:
return 2
added_head_only = head_allowlist - base_allowlist
# Refuse the bypass shape where a PR introduces a new postinstall
# dep AND allowlists it in the same diff -- head-only allowlist
# entries that match install-script findings in the same PR are
# rejected. Entries that match no current finding are allowed:
# they prepare the trust list for a follow-up PR that actually
# adds the dependency, and the human-review path still sees the
# allowlist diff before that follow-up can land.
# Refuse "introduce a new postinstall AND allowlist it in the same
# diff". Head-only entries that match no current finding are fine
# (prepare trust for a follow-up PR; reviewer still sees the diff).
self_approving = sorted(added_head_only & raw_findings_keys)
if self_approving:
print(
@ -380,16 +343,9 @@ def main(argv: list[str] | None = None) -> int:
)
return 1
# Refuse a PR that DROPS trusted base entries. Without this,
# an attacker could land a two-step bypass:
# 1. PR A removes ``.install-script-allowlist`` from base
# (no new lockfile findings -> passes today).
# 2. PR B then hits the bootstrap path (base allowlist
# missing) and self-allowlists a newly introduced
# install-script dependency.
# Allowlist deletions are rare; doing them via an
# admin-override / branch-protection bypass keeps the
# bootstrap path closed for the everyday gate.
# Refuse PRs that DROP trusted base entries; otherwise a two-step
# bypass works (PR A removes file -> PR B hits bootstrap and
# self-allowlists). Deletions go via admin override.
removed_from_head = sorted(base_allowlist - head_allowlist)
if removed_from_head:
print(
@ -406,10 +362,8 @@ def main(argv: list[str] | None = None) -> int:
)
return 1
# Only the trusted base allowlist participates in the skip set.
# Head-only entries that survived the self-approval check above
# are deliberately NOT honored here -- they wait for the next
# PR (running against this PR's merged base) to take effect.
# Only base allowlist applies. Head-only entries that survived
# the self-approval check wait for the next PR to take effect.
allowlist = base_allowlist
findings = raw_findings
@ -439,10 +393,9 @@ def main(argv: list[str] | None = None) -> int:
print(str(f), file = sys.stderr)
print(file = sys.stderr)
print(
"[install-script-diff] Refusing to proceed. Every new "
"install-script dep is a postinstall lifecycle hook that "
"would run on the next `npm ci`. Review each finding above, "
"confirm the maintainer + version, and re-run.",
"[install-script-diff] Refusing to proceed. Each new install-script "
"dep ships a postinstall hook that runs on next `npm ci`. Review, "
"confirm maintainer + version, and re-run.",
file = sys.stderr,
)
return 1

View file

@ -330,14 +330,9 @@ 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. SVG previews still
# use a sandboxed ``srcdoc`` iframe (no URL fetch); interactive
# HTML previews go through the same-origin ``/api/preview/html/{id}``
# route in routes/html_preview.py, which serves the snippet with
# its own overriding ``script-src 'unsafe-inline'`` response CSP.
# Without the same-origin route, Chromium would inherit THIS
# ``script-src 'self'`` for srcdoc / data: / blob: iframes per
# HTML / CSP3 and inline scripts would be silently dead.
# Same-origin only. SVG uses srcdoc; interactive HTML goes through
# /api/preview/html/{id} which carries its own overriding response
# CSP (srcdoc / data: / blob: inherit THIS one per CSP3).
"frame-src 'self'; "
"frame-ancestors 'none'; "
"form-action 'self'; "

View file

@ -3,34 +3,21 @@
"""HTML preview route for assistant ```html fences.
Reason this route exists: when the chat renderer embeds the assistant's
HTML via a ``srcdoc`` iframe, Chromium inherits the embedder CSP
(``script-src 'self'``), so inline scripts and ``onclick`` handlers are
silently blocked. Serving the same HTML from a same-origin URL with an
overriding response-header CSP is the only way to let assistant-generated
interactive HTML actually run while keeping the surrounding Studio CSP
strict.
srcdoc / data: / blob: iframes inherit the host CSP (script-src 'self')
per CSP3, so inline scripts and onclick handlers are silently blocked.
Serving the snippet from a same-origin URL with an overriding response
CSP is the only way to unlock interactive HTML.
Security shape:
* POST is auth-gated (``get_current_subject``). Only an authenticated
caller can stash HTML into the in-memory store.
* GET is intentionally NOT auth-gated -- browsers do not attach the
Authorization bearer to iframe subresource loads, so we instead make
the URL itself the secret: ``secrets.token_urlsafe(24)`` (192 bits of
entropy). The token leaves the server only in the POST response and
is then placed into the iframe ``src`` by the requesting page. It is
never persisted to disk, never logged, and is wiped on TTL expiry.
* The response CSP is ``default-src 'none'`` + ``script-src
'unsafe-inline'`` so the preview is sandboxed from the network but
inline scripts and event-handler attributes execute as intended.
* The iframe still has ``sandbox="allow-scripts allow-modals
allow-popups"`` (no ``allow-same-origin``), so even though the URL
is same-origin the document is treated as a unique opaque origin
for SOP purposes -- script in the preview cannot reach
``window.parent`` storage, cookies, or DOM.
* A size cap and TTL cap bound the in-memory footprint per Studio
process.
Security:
- POST is auth-gated.
- GET is NOT auth-gated -- iframe subresource loads do not carry the
Authorization bearer, so the URL token (192 bits, secrets.token_urlsafe(24))
is the authorisation. Never persisted, wiped on TTL expiry.
- Response CSP: default-src 'none' + script-src 'unsafe-inline'.
Scripts run; network egress (connect-src, frame-src, worker-src) blocked.
- iframe sandbox stays "allow-scripts allow-modals allow-popups" with NO
allow-same-origin, so preview JS cannot reach window.parent.
- Size and live-entry caps bound per-process memory.
"""
from __future__ import annotations
@ -44,8 +31,7 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
# Backend root on sys.path so ``auth`` imports resolve when this module
# is loaded standalone (matches the pattern used by routes/export.py).
# Backend on sys.path for standalone load (matches routes/export.py).
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
@ -55,30 +41,14 @@ from auth.authentication import get_current_subject # noqa: E402
router = APIRouter()
# ---------------------------------------------------------------------------
# Knobs (module-level so tests can monkeypatch).
# ---------------------------------------------------------------------------
# 1 MiB cap; assistant HTML previews are short snippets, not full SPAs.
MAX_HTML_PREVIEW_BYTES = 1_000_000
# 10 minutes. Long enough for a user to interact with the preview, short
# enough that a forgotten tab does not pin the entry.
PREVIEW_TTL_SECONDS = 10 * 60
# Defensive cap so a runaway producer cannot exhaust the worker. New POSTs
# evict the oldest entries past this watermark. Per-process, in-memory only.
MAX_LIVE_PREVIEWS = 256
MAX_HTML_PREVIEW_BYTES = 1_000_000 # 1 MiB; snippets, not full SPAs.
PREVIEW_TTL_SECONDS = 10 * 60 # 10 min interaction window.
MAX_LIVE_PREVIEWS = 256 # Per-process cap; oldest evicted first.
_PREVIEWS: dict[str, tuple[float, str]] = {}
# ---------------------------------------------------------------------------
# Internal helpers.
# ---------------------------------------------------------------------------
def _sweep_expired(now: float | None = None) -> None:
now = time.monotonic() if now is None else now
expired = [k for k, (t, _) in _PREVIEWS.items() if now - t > PREVIEW_TTL_SECONDS]
@ -89,31 +59,24 @@ def _sweep_expired(now: float | None = None) -> None:
def _evict_overflow() -> None:
if len(_PREVIEWS) <= MAX_LIVE_PREVIEWS:
return
# Evict oldest first.
sorted_keys = sorted(_PREVIEWS, key = lambda k: _PREVIEWS[k][0])
for k in sorted_keys[: len(_PREVIEWS) - MAX_LIVE_PREVIEWS]:
_PREVIEWS.pop(k, None)
def _build_html_doc(source: str) -> str:
# ``<base target="_blank">`` mirrors the srcdoc fallback so any ``<a>``
# without an explicit target opens in a new tab rather than navigating
# the iframe (which would be UX-confusing).
# <base target="_blank"> so <a> links open a new tab instead of
# navigating the iframe away from the preview.
return "<!doctype html>" '<base target="_blank">' + source
_PREVIEW_CSP = "; ".join(
(
"default-src 'none'",
# ``script-src 'unsafe-inline'`` enables BOTH ``<script>`` blocks and
# ``onclick``-style attribute handlers. This is the entire reason the
# route exists -- the host page's ``script-src 'self'`` does not.
# The reason this route exists -- host CSP does not allow inline.
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
# ``data:`` / ``blob:`` only, NOT remote http(s). Inline JS cannot
# exfiltrate by fetching a remote pixel since ``connect-src 'none'``
# blocks fetch/XHR, but stripping remote ``img-src`` removes the
# other classic beacon vector too.
# data: / blob: only -- no remote img beacon.
"img-src data: blob:",
"media-src data: blob:",
"font-src data:",
@ -123,20 +86,12 @@ _PREVIEW_CSP = "; ".join(
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
# Restrict who can embed THIS preview. The Studio host page is
# same-origin and is the only legitimate embedder. ``frame-ancestors
# 'self'`` also overrides any global X-Frame-Options on modern
# browsers, so a third-party site cannot iframe a leaked preview URL.
# Same-origin embedders only; also overrides X-Frame-Options.
"frame-ancestors 'self'",
)
)
# ---------------------------------------------------------------------------
# Request / response models.
# ---------------------------------------------------------------------------
class HtmlPreviewCreate(BaseModel):
source: str = Field(..., max_length = MAX_HTML_PREVIEW_BYTES)
@ -146,24 +101,15 @@ class HtmlPreviewCreateResponse(BaseModel):
expires_in_seconds: int
# ---------------------------------------------------------------------------
# Endpoints.
# ---------------------------------------------------------------------------
@router.post("", response_model = HtmlPreviewCreateResponse)
async def create_html_preview(
payload: HtmlPreviewCreate,
current_subject: str = Depends(get_current_subject),
) -> HtmlPreviewCreateResponse:
"""Stash an HTML snippet for same-origin iframe rendering.
Returns a same-origin URL whose path includes a 192-bit random token.
The token is the only authorisation for the subsequent GET.
"""
"""Stash HTML; return a same-origin token URL (the only auth for GET)."""
_sweep_expired()
source = payload.source
if not isinstance(source, str): # defensive; pydantic enforces str already
if not isinstance(source, str): # defensive; pydantic already enforces.
raise HTTPException(status_code = 400, detail = "source must be a string")
if len(source) > MAX_HTML_PREVIEW_BYTES:
raise HTTPException(status_code = 413, detail = "HTML preview too large")
@ -178,12 +124,7 @@ async def create_html_preview(
@router.get("/{preview_id}", response_class = HTMLResponse)
async def get_html_preview(preview_id: str) -> HTMLResponse:
"""Serve a stashed HTML snippet with an overriding response CSP.
Intentionally NOT auth-gated: the URL token IS the authorisation.
The iframe in the chat page has no Authorization header to send,
so making this require a bearer would break the only consumer.
"""
"""Serve a stashed snippet with overriding CSP. Unauth: token IS the auth."""
_sweep_expired()
item = _PREVIEWS.get(preview_id)
if item is None:
@ -196,9 +137,7 @@ async def get_html_preview(preview_id: str) -> HTMLResponse:
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
# Override the global SecurityHeadersMiddleware default of
# ``X-Frame-Options: DENY`` -- otherwise the preview page
# refuses to be iframed by the host chat view at all.
# Override the global DENY default so the host chat can iframe us.
"X-Frame-Options": "SAMEORIGIN",
},
)

View file

@ -19,7 +19,7 @@ if str(_BACKEND_ROOT) not in sys.path:
@pytest.fixture
def preview_app(tmp_path, monkeypatch):
"""Standalone app mounting only the html-preview router on a clean store."""
"""Standalone app with only the html-preview router and a clean store."""
from auth import storage
from auth.authentication import create_access_token
@ -39,8 +39,7 @@ def preview_app(tmp_path, monkeypatch):
from routes.html_preview import router as html_preview_router
from routes import html_preview as html_preview_module
# Each test starts with an empty in-memory store.
html_preview_module._PREVIEWS.clear()
html_preview_module._PREVIEWS.clear() # fresh store per test
app = FastAPI()
app.include_router(
@ -80,14 +79,13 @@ class TestPostHtmlPreview:
json = {"source": too_big},
headers = {"Authorization": f"Bearer {token}"},
)
# Pydantic enforces the max_length so this is a 422 (validation),
# NOT a 413 -- but either is acceptable as long as it does not get
# stored. We assert non-2xx and an empty store.
# Pydantic's max_length surfaces as 422; we accept any non-2xx
# as long as nothing is stored.
assert r.status_code >= 400
assert mod._PREVIEWS == {}
def test_post_returns_unguessable_tokens(self, preview_app):
# Two POSTs of the same source must produce two distinct tokens.
# Identical source -> distinct tokens.
app, token, _mod = preview_app
c = TestClient(app)
urls = set()
@ -119,34 +117,29 @@ class TestGetHtmlPreview:
r = c.get(url)
assert r.status_code == 200
body = r.text
# The doctype + base + body are present.
assert "<!doctype html>" in body.lower()
assert '<base target="_blank">' in body
assert "<button onclick=\"alert('x')\">go</button>" in body
# The overriding CSP must permit inline script execution.
csp = r.headers["content-security-policy"]
directives = {
chunk.strip().split(" ", 1)[0]: chunk.strip()
for chunk in csp.split(";")
if chunk.strip()
}
assert "default-src" in directives
# Inline scripts allowed, network egress closed.
assert "'none'" in directives["default-src"]
assert "'unsafe-inline'" in directives["script-src"]
# Beacon paths are still closed.
assert "'none'" in directives["connect-src"]
assert "'none'" in directives["frame-src"]
# Only same-origin embedders may iframe the preview.
assert "frame-ancestors" in directives
# Same-origin embedders only.
assert "'self'" in directives["frame-ancestors"]
# X-Frame-Options is SAMEORIGIN so the host page can iframe us.
# Override the global DENY so host chat can iframe us.
assert r.headers["x-frame-options"].upper() == "SAMEORIGIN"
# No caching of preview bodies.
assert "no-store" in r.headers["cache-control"]
def test_get_is_not_auth_gated(self, preview_app):
# Browsers do not attach Authorization to iframe subresource loads.
# The unguessable URL token IS the authorisation.
# Iframe subresource loads do not carry Authorization; the URL
# token is the authorisation.
app, token, _mod = preview_app
url = self._create(app, token, "<p>nope</p>")
c = TestClient(app)
@ -162,14 +155,14 @@ class TestGetHtmlPreview:
def test_get_expired_token_is_404(self, preview_app):
app, token, mod = preview_app
url = self._create(app, token, "<p>aging</p>")
# Force-age the stored entry past the TTL by rewinding monotonic.
# Rewind monotonic past the TTL.
token_id = url.rsplit("/", 1)[-1]
created, src = mod._PREVIEWS[token_id]
mod._PREVIEWS[token_id] = (created - (mod.PREVIEW_TTL_SECONDS + 5), src)
c = TestClient(app)
r = c.get(url)
assert r.status_code == 404
# And the entry is swept on access.
# Swept on access.
assert token_id not in mod._PREVIEWS
@ -177,8 +170,7 @@ class TestEviction:
def test_overflow_evicts_oldest_entries(self, preview_app):
app, token, mod = preview_app
c = TestClient(app)
# Pin the cap low so the test is cheap.
mod.MAX_LIVE_PREVIEWS = 4
mod.MAX_LIVE_PREVIEWS = 4 # cheap test
urls = []
for i in range(6):
r = c.post(
@ -187,14 +179,13 @@ class TestEviction:
headers = {"Authorization": f"Bearer {token}"},
)
urls.append(r.json()["url"])
# Force monotonic progression so eviction order is deterministic.
time.sleep(0.001)
time.sleep(0.001) # deterministic monotonic order
assert len(mod._PREVIEWS) == mod.MAX_LIVE_PREVIEWS
# The two oldest tokens (urls[0], urls[1]) must have been evicted.
# Oldest two evicted.
for old in urls[:2]:
token_id = old.rsplit("/", 1)[-1]
assert token_id not in mod._PREVIEWS
# Newer tokens are still present.
# Newest survive.
for fresh in urls[-mod.MAX_LIVE_PREVIEWS :]:
token_id = fresh.rsplit("/", 1)[-1]
assert token_id in mod._PREVIEWS

View file

@ -79,8 +79,7 @@ class TestMaxBodyMiddleware:
assert r.json()["unprotected"] is True
def test_chunked_upload_over_cap_rejected(self, main_module):
# Regression: declared-Content-Length-only check could be bypassed
# by chunked transfer-encoding.
# Regression: Content-Length-only check missed chunked uploads.
app = _make_protected_app(1024, main_module)
c = TestClient(app)
@ -156,7 +155,7 @@ class TestSecurityHeadersMiddleware:
r = c.get("/plain")
assert r.status_code == 200
csp = r.headers["content-security-policy"]
# Parse per-directive so style-src unsafe-inline does not false-match.
# Parse per directive so style-src unsafe-inline does not false-match.
directives = {
chunk.strip().split(" ", 1)[0]: chunk.strip()
for chunk in csp.split(";")
@ -184,7 +183,7 @@ class TestSecurityHeadersMiddleware:
r = c.get("/with-nonce")
csp = r.headers["content-security-policy"]
assert f"'nonce-{nonce}'" in csp
# Internal handoff header must not leak to clients.
# Internal handoff header must not leak.
assert main_module._CSP_SCRIPT_NONCE_HEADER not in {
k.lower() for k in r.headers.keys()
}
@ -197,12 +196,10 @@ 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). 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.
# SVG preview uses srcdoc, HTML preview uses /api/preview/html
# (same-origin). srcdoc / data: / blob: all inherit this CSP per
# CSP3, so broadening here would not unlock scripts. Explicit
# 'self' leaves a visible directive future changes must broaden.
csp = main_module._build_csp()
frame_src = next(
chunk.strip()
@ -216,16 +213,16 @@ class TestSecurityHeadersMiddleware:
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.
# sources.tsx fetches https://www.google.com/s2/favicons?...;
# without this, citation favicons fall back to grey initials.
csp = main_module._build_csp()
img_directive = next(
chunk.strip()
for chunk in csp.split(";")
if chunk.strip().startswith("img-src ")
)
# Tokenise and compare with `==` so CodeQL's URL-substring rule does
# not read directive-string `in` membership as URL sanitisation.
# Tokenise + `==`: CodeQL's URL-substring rule would otherwise
# read `in` membership as URL sanitisation.
img_sources = img_directive.split()
assert any(src == "https://www.google.com" for src in img_sources)
# Pre-existing favicon CDNs stay allowed.
@ -269,9 +266,9 @@ def health_app(tmp_path, monkeypatch):
class TestHealthAuthGate:
# Launcher / frontend bootstrap fields are available unauth so the Tauri
# watchdog can re-adopt a sibling backend and the SPA can detect chat-only
# mode before any token exists. Version / device_type still require a bearer.
# Bootstrap fields are unauth so the Tauri watchdog can re-adopt a
# sibling backend and SPA can detect chat-only before any token exists.
# Version / device_type still require a bearer.
LAUNCHER_BITS = (
"service",
"studio_root_id",
@ -298,7 +295,7 @@ class TestHealthAuthGate:
assert forbidden not in body
def test_invalid_bearer_returns_launcher_bits_only(self, health_app):
# Regression: calling the async dep without await made any Bearer header pass.
# Regression: missing await on the async dep let any Bearer pass.
c = TestClient(health_app)
r = c.get(
"/api/health",

View file

@ -1,17 +1,11 @@
# Packages whose npm install-script (postinstall) we have eyeballed and
# accepted. The new-install-script gate (scripts/check_new_install_scripts.py)
# refuses any newly-added install-script dep by default; entries listed here
# are explicitly skipped.
# Reviewed install-script (postinstall) packages, exempt from
# scripts/check_new_install_scripts.py. Pin each as "name@version" -- a
# maintainer compromise shipping a new malicious version must NOT slip
# through the same allowlist line.
#
# Pin EACH entry to an exact "name@version" so a maintainer compromise that
# ships a new malicious version is not silently swept under the same line.
# Lines starting with "#" are comments; blank lines are ignored.
#
# DO NOT add packages here without reading the actual install script body.
# Allowlist entries MUST land on main first; the gate rejects head-only
# additions so a PR cannot allowlist its own new postinstall dep.
# Rules: read the install body before adding. Entries MUST land on main
# first; the gate rejects head-only additions that self-approve a new dep.
# evanw/esbuild downloads the platform-specific native binary
# (esbuild-linux-x64, etc.) in its postinstall. Used transitively by
# vitest for dev-only test transforms; no runtime exposure.
# evanw/esbuild: postinstall downloads platform native binary. Pulled in
# transitively by vitest for dev-only test transforms; no runtime exposure.
esbuild@0.21.5

View file

@ -12,9 +12,8 @@ import {
sanitizeSvgSource,
} from "../html-svg-renderer";
// HtmlPreview POSTs the source to /api/preview/html to obtain a same-origin
// URL whose response CSP permits inline scripts. Tests fake this round-trip
// so the iframe enters a deterministic post-load state.
// Fake the /api/preview/html POST round-trip so the iframe reaches a
// deterministic post-load state under jsdom.
let _previewIdCounter = 0;
function installFetchStub(): void {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
@ -54,16 +53,9 @@ describe("HtmlSvgRenderer", () => {
"html-svg-renderer-iframe",
) as HTMLIFrameElement;
expect(iframe.tagName).toBe("IFRAME");
// SECURITY: allow-scripts + allow-modals let assistant inline scripts
// / alert / confirm run in the backend-served preview, which carries
// a response CSP wide enough to execute them. allow-popups lets
// ``<base target="_blank">`` links open without being silently
// dropped; popups INHERIT the sandbox (allow-popups-to-escape-sandbox
// is intentionally absent) so an opened tab cannot use
// window.opener.top.location.* to tabnab the Studio tab.
// allow-same-origin and allow-top-navigation are NEVER granted, so
// even though the URL is same-origin the iframe document is treated
// as a unique opaque origin and cannot reach window.parent.
// allow-scripts/allow-modals/allow-popups grant the runtime the
// route CSP unlocks; NOT granting allow-same-origin / allow-top-nav /
// allow-popups-to-escape-sandbox blocks parent access and tabnabbing.
const sandbox = iframe.getAttribute("sandbox") ?? "";
const sandboxTokens = sandbox.split(/\s+/);
expect(sandboxTokens).toContain("allow-scripts");
@ -73,12 +65,10 @@ describe("HtmlSvgRenderer", () => {
expect(sandbox).not.toContain("allow-same-origin");
expect(sandbox).not.toContain("allow-top-navigation");
// While the preview API call is in-flight the iframe holds
// about:blank rather than flashing the previous preview.
// In-flight: about:blank (no flash of previous preview).
expect(["about:blank", null]).toContain(iframe.getAttribute("src"));
// After the POST resolves, the iframe src points at the same-origin
// preview URL the backend returned.
// After POST resolves: iframe src is the returned same-origin URL.
await waitFor(() => {
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
});
@ -117,17 +107,14 @@ describe("HtmlSvgRenderer", () => {
"html-svg-renderer-svg-preview",
) as HTMLIFrameElement;
expect(iframe.tagName).toBe("IFRAME");
// SECURITY: SVG iframe must NEVER allow scripts or same-origin -- those
// would re-introduce the host-page-leak / XSS regressions the iframe
// boundary is here to prevent.
// SVG iframe must NEVER allow scripts or same-origin (XSS / host leak).
expect(iframe.getAttribute("sandbox")).toBe("");
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toLowerCase();
expect(srcdoc).toContain("<circle");
expect(srcdoc).not.toContain("<script");
expect(srcdoc).not.toContain("onclick");
expect(srcdoc).not.toContain("alert");
// CSP is the second line of defence: block all network egress except
// data: images so a future sanitizer regression cannot beacon out.
// CSP backstop: data: images only so a sanitizer regression cannot beacon.
expect(srcdoc).toContain("default-src 'none'");
});
@ -147,8 +134,7 @@ describe("HtmlSvgRenderer", () => {
) as HTMLIFrameElement;
expect(iframe).toBeTruthy();
expect(screen.queryByTestId("custom-code-view")).toBeNull();
// Wait for the preview POST to settle so the act() warning that follows
// an async state update outside an act() block does not fire.
// Settle the preview POST so it does not raise an out-of-act() warning.
await waitFor(() => {
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
});
@ -180,8 +166,7 @@ describe("HtmlSvgRenderer", () => {
const root = screen.getByTestId("html-svg-renderer");
expect(root.getAttribute("data-active-tab")).toBe("code");
// Preview tab is rendered but disabled while incomplete so the user can
// see the streaming tokens without flicker.
// Preview disabled while streaming so users see tokens, not flicker.
const previewTab = screen.getByRole("tab", { name: /preview/i });
expect(previewTab.hasAttribute("disabled")).toBe(true);
});
@ -195,7 +180,6 @@ describe("HtmlSvgRenderer", () => {
await waitFor(() => {
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
});
// The fetch stub installed in beforeEach captured exactly one call.
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock
.calls[0] as [string, RequestInit];
@ -204,8 +188,7 @@ describe("HtmlSvgRenderer", () => {
expect(init.credentials).toBe("same-origin");
const parsedBody = JSON.parse(init.body as string) as { source: string };
expect(parsedBody.source).toBe(html);
// After the API returns, the iframe src holds the returned same-origin
// path (no srcdoc); the backend response CSP is what unlocks scripts.
// iframe lands on the token URL; no srcdoc.
const src = iframe.getAttribute("src") ?? "";
expect(src.startsWith("/api/preview/html/")).toBe(true);
expect(iframe.getAttribute("srcdoc")).toBeNull();
@ -233,8 +216,7 @@ describe("HtmlSvgRenderer", () => {
previewTab.getAttribute("id"),
);
// Roving tabindex: active tab is reachable, inactive is taken out of the
// tab order per WAI-ARIA APG tab pattern.
// Roving tabindex per WAI-ARIA APG tab pattern.
expect(previewTab.getAttribute("tabindex")).toBe("0");
expect(codeTab.getAttribute("tabindex")).toBe("-1");
});
@ -248,8 +230,7 @@ describe("HtmlSvgRenderer", () => {
"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.
// Cap BOTH dimensions or a square viewBox clips vertically.
expect(srcdoc).toContain("max-width:100%");
expect(srcdoc).toContain("max-height:100%");
expect(srcdoc).toContain("height:100%");
@ -276,11 +257,8 @@ describe("parseIncompleteCodeFence", () => {
});
it("recovers a final-but-never-closed fence (small LLMs drop the closing ```)", () => {
// Regression: live probe against Qwen3-0.6B caught the model emitting a
// complete SVG body but no closing ```. parseCodeFence rejects (strict
// ``` ... ``` shape), so the StreamdownBlock fallback must still find the
// fence via parseIncompleteCodeFence even after streaming completes -- or
// HtmlSvgRenderer never mounts and the user sees a plain code block.
// Regression caught by live Qwen3-0.6B probe: complete body, no closing
// ```. Without this path HtmlSvgRenderer would not mount.
const finalNoClose =
"```svg\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 10 10\">" +
"<circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"orange\"/></svg>";
@ -325,8 +303,7 @@ describe("Fence helpers", () => {
});
it("non-HTML / non-SVG fences are not handled by the renderer", () => {
// The markdown pipeline only invokes HtmlSvgRenderer when these helpers
// agree the fence is html/svg. A python fence must fall through.
// markdown-text.tsx invokes HtmlSvgRenderer only when these agree.
const fence = parseCodeFence("```python\nprint('hi')\n```");
expect(fence).not.toBeNull();
if (!fence) return;
@ -357,12 +334,8 @@ describe("sanitizeSvgSource", () => {
});
it("keeps inline <style> blocks so class-styled SVG exports still render", () => {
// The SVG preview iframe is fully sandboxed (sandbox="") and the
// inner CSP is default-src 'none', so the iframe's <style> cannot
// reach the host page selectors or fetch external URLs (CSP blocks
// @import / url(...)). Stripping <style> broke legitimate class-
// styled SVG exports from many diagram tools, which is the bigger
// real-world cost than the (already-mitigated) selector leak.
// Iframe sandbox="" + default-src 'none' contain <style> blast radius;
// diagram exporters relying on class styles outweigh the selector leak.
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><style>.fg{fill:red}</style><rect class="fg"/></svg>`;
const clean = sanitizeSvgSource(svg).toLowerCase();
expect(clean).toContain("<style");
@ -408,7 +381,7 @@ describe("sanitizeSvgSource", () => {
"<circle fill=\"url(#g1)\" r=\"10\"/>" +
"</svg>";
const clean = sanitizeSvgSource(svg).toLowerCase();
// Fragment hrefs must survive so textPath/gradient refs still resolve.
// Fragment hrefs must survive so textPath / gradient refs resolve.
expect(clean).toContain("href=\"#labelpath\"");
});

View file

@ -24,11 +24,9 @@ export type HtmlSvgLanguage = "html" | "svg";
export type HtmlSvgRendererProps = {
language: HtmlSvgLanguage;
source: string;
// Pre-rendered syntax-highlighted code view. Optional; if omitted the
// renderer falls back to a plain <pre><code> block.
// Optional syntax-highlighted code view; falls back to plain <pre><code>.
codeView?: ReactNode;
// When the markdown stream is still arriving the fence may be partial.
// In that case we force the Code tab and disable the toggle controls.
// Partial fence: lock to Code tab and disable toggle.
isIncomplete?: boolean;
};
@ -36,32 +34,17 @@ const DEFAULT_PREVIEW_HEIGHT = 500;
const POPOUT_HEIGHT_VH = 80;
const COPY_RESET_MS = 2000;
// Conservative regex covering the same vectors we previously stripped before
// DOMPurify was wired in. DOMPurify itself does the heavy lifting; this is
// belt-and-braces so a quick visual inspection of the source still catches the
// usual XSS hot spots.
// Belt-and-braces pre-screen; DOMPurify does the real work below.
const HEURISTIC_UNSAFE_SVG_RE =
/<script[\s>]|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
// SVG previews used to live inside a `<img src="data:image/svg+xml,...">` tag
// where the browser treats the SVG as an image and disables scripts and
// external resource loads. Mounting sanitized SVG directly into the host
// Studio document loses those guarantees, so we now (a) strip every node that
// can leak into the host page (`<style>`, `<image>`, `<use>`, scripts, etc.)
// and (b) render the surviving markup in a fully sandboxed iframe at
// `SvgPreview` below for defence in depth. See:
// https://developer.mozilla.org/en-US/docs/Web/SVG/Guides/SVG_as_an_image
// SVG sanitizer config. Surviving markup is rendered in the sandboxed
// SvgPreview iframe below as a second layer.
const SVG_PURIFY_CONFIG = {
USE_PROFILES: { svg: true, svgFilters: true },
// ``image`` / ``use`` -- carry ``href``/``xlink:href`` and would let an
// assistant fetch attacker-controlled URLs from the user's browser.
// ``foreignObject`` -- can embed HTML inside the SVG and re-introduce XSS.
// ``script`` / ``link`` / ``meta`` / ``iframe`` / ``embed`` / ``object``
// are unconditional XSS / network surfaces. ``<style>`` is kept --
// the SVG preview iframe is fully sandboxed (``sandbox=""``) with a
// ``default-src 'none'`` CSP, so class-based styling that real
// diagram exporters emit cannot leak to the host page or fetch
// external URLs (the CSP blocks ``@import`` and ``url(...)``).
// image/use carry href, foreignObject embeds HTML, script/link/meta/iframe/
// embed/object are XSS or network surfaces. <style> stays -- the iframe
// sandbox + default-src 'none' CSP cap its blast radius.
FORBID_TAGS: [
"script",
"foreignObject",
@ -73,25 +56,16 @@ const SVG_PURIFY_CONFIG = {
"link",
"meta",
],
// 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.
// filter / mask / clip-path accept url(...) which fetches at render time;
// style allows inline @import / url(...). href / xlink:href stay so safe
// same-doc fragments survive -- external schemes pruned in the hook below.
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.
// Pin href / xlink:href to same-doc fragments. Done as a hook because
// DOMPurify's ALLOWED_URI_REGEXP also rejects presentation attrs (cx, r, ...).
// Installed once at module load.
const FRAGMENT_HREF_HOOK_TAG = "__unsloth_svg_frag_href__";
const URI_ATTRS = new Set(["href", "xlink:href"]);
@ -107,12 +81,10 @@ if (!(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG])
true;
}
/** Strip every XML processing instruction and disallowed node from an SVG. */
/** Strip XML PIs and disallowed nodes from an SVG. */
export function sanitizeSvgSource(source: string): string {
// Drop XML declarations -- DOMPurify keeps them but some renderers choke.
// Drop XML declarations -- DOMPurify keeps them, some renderers choke.
const stripped = source.replace(/^\s*<\?xml[^?]*\?>\s*/i, "");
// First pass: regex screen. We do NOT bail out -- DOMPurify will still
// produce a safe string -- but logging here helps debugging.
if (HEURISTIC_UNSAFE_SVG_RE.test(stripped)) {
// eslint-disable-next-line no-console
console.debug("SVG renderer: stripping unsafe nodes before sanitize");
@ -209,10 +181,7 @@ function TabButton({
);
}
// SVG preview goes inside a sandboxed iframe (no allow-scripts, no
// allow-same-origin) plus a `default-src 'none'` CSP so the sanitizer is
// not the only line of defence -- even if a future DOMPurify regression
// leaks a URL-bearing attribute, the browser blocks the request.
// Defence in depth on top of the sanitizer.
const SVG_IFRAME_CSP =
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:;";
@ -220,9 +189,7 @@ function buildSvgSrcDoc(safeSvg: string): string {
return [
"<!doctype html>",
`<meta http-equiv="Content-Security-Policy" content="${SVG_IFRAME_CSP}">`,
// 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.
// Cap both dimensions so a square viewBox does not clip vertically.
"<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>",
@ -239,9 +206,7 @@ function SvgPreview({ source }: { source: string }) {
data-testid="html-svg-renderer-svg-preview"
title="SVG preview"
srcDoc={srcDoc}
// SECURITY: sandbox="" forbids scripts AND blocks the iframe from
// inheriting the host origin, so even sanitized SVG cannot reach the
// Studio document or run network requests against host-cookied URLs.
// No scripts, no same-origin: SVG cannot touch host document or network.
sandbox=""
style={{
width: "100%",
@ -254,17 +219,14 @@ 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.
// Iframe posts its scrollHeight to the parent for auto-sizing. One-way:
// no allow-same-origin, so iframe cannot read parent.document.
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>';
// Fallback for when the same-origin preview route is unreachable (offline,
// 404, transport error). srcdoc inherits the host page's ``script-src
// 'self'`` per HTML / CSP3, so this path is static-layout-only -- inline
// ``<script>`` and ``onclick`` handlers will NOT execute. The interactive
// surface is the /api/preview/html backend route below.
// Fallback for when the preview route is unreachable. srcdoc inherits the
// host script-src 'self' per CSP3 so inline scripts will NOT run on this
// path -- it is layout-only. Interactive surface is the API route below.
const HTML_IFRAME_CSP = [
"default-src 'none'",
"script-src 'self' 'unsafe-inline'",
@ -290,13 +252,11 @@ function buildHtmlSrcDoc(source: string): string {
].join("");
}
// Backend route that serves the HTML with a response-header CSP wide enough
// to let ``<script>`` and ``onclick`` fire. Created on every source change
// via POST; the returned random-token URL becomes the iframe ``src``.
// Same-origin route whose response CSP permits inline scripts. POST source,
// use returned random-token URL as iframe src.
const HTML_PREVIEW_API = "/api/preview/html";
// Read the bearer the same way the rest of the app does. Pulled lazily to
// avoid any import cycle while keeping the auth-key contract in one place.
// Same key as features/auth/session.ts:AUTH_TOKEN_KEY; inlined to avoid cycle.
function getStoredAccessToken(): string | null {
if (typeof window === "undefined") return null;
try {
@ -321,11 +281,8 @@ function HtmlPreview({
onHeightChange?: (h: number | null) => void;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// POST the source to the backend preview route. The backend stores it for
// 10 minutes and returns a same-origin URL whose ``script-src`` permits
// inline execution -- the only way to escape the host CSP for srcdoc /
// data: / blob: iframes (which all inherit the embedder policy per
// HTML / CSP3).
// POST source -> token URL. srcdoc / data: / blob: inherit the host CSP
// per CSP3, so a same-origin route is the only way to unlock inline scripts.
const [previewState, setPreviewState] = useState<PreviewState>({
kind: "loading",
});
@ -392,8 +349,8 @@ function HtmlPreview({
? "100%"
: Math.min(autoHeight ?? DEFAULT_PREVIEW_HEIGHT, DEFAULT_PREVIEW_HEIGHT);
// Error path: fall back to srcdoc so the preview still renders the layout
// (static-only -- scripts dead) instead of going blank.
// Backend unreachable: fall back to srcdoc (static layout, no scripts)
// instead of going blank.
const errorSrcDoc = useMemo(
() => (previewState.kind === "error" ? buildHtmlSrcDoc(source) : null),
[previewState.kind, source],
@ -407,20 +364,15 @@ function HtmlPreview({
title="HTML preview"
src={previewState.kind === "ready" ? previewState.url : "about:blank"}
srcDoc={errorSrcDoc ?? undefined}
// SECURITY:
// allow-scripts -- inline <script> / on* handlers run in the
// backend-served preview, which carries
// ``script-src 'unsafe-inline'``
// allow-modals -- alert / confirm / prompt are not no-ops
// allow-popups -- ``<base target="_blank">`` links can open
// a new tab instead of silently dropping
// We do NOT grant:
// allow-scripts: inline JS runs (route CSP permits it).
// allow-modals: alert / confirm / prompt usable.
// allow-popups: <base target="_blank"> links open a tab.
// NOT granted:
// allow-same-origin / allow-top-navigation -- preview JS cannot
// read parent.document or navigate the host page even though
// the URL is same-origin
// allow-popups-to-escape-sandbox -- popups INHERIT the sandbox
// so an opened tab cannot use ``window.opener.top.location``
// to tabnab the Studio tab
// reach parent.document or navigate the host (URL is same-origin
// but iframe is treated as opaque).
// allow-popups-to-escape-sandbox -- opened tabs inherit sandbox so
// window.opener.top.location.* tabnabbing is blocked.
sandbox="allow-scripts allow-modals allow-popups"
style={{
width: "100%",
@ -439,14 +391,11 @@ export function HtmlSvgRenderer({
codeView,
isIncomplete,
}: HtmlSvgRendererProps) {
// Stream-in: while the fence is still being filled in we lock to the Code
// tab so users see the in-flight tokens rather than a flashing preview.
// Lock to Code while streaming so users see tokens, not a flashing preview.
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.
// Lifted from HtmlPreview so the pop-out spacer can match current height.
const [htmlHeight, setHtmlHeight] = useState<number | null>(null);
const activeTab: TabKey = lockedToCode ? "code" : tab;
@ -491,10 +440,8 @@ export function HtmlSvgRenderer({
);
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.
// Match live iframe height so popping out a short preview does not leave
// a 500px hole. Falls back to DEFAULT before the first height post.
const popoutSpacerHeight = Math.min(
htmlHeight ?? DEFAULT_PREVIEW_HEIGHT,
DEFAULT_PREVIEW_HEIGHT,
@ -533,10 +480,9 @@ export function HtmlSvgRenderer({
</div>
<div className="flex items-center gap-1">
{activeTab === "code" && !codeView ? (
// When a custom code view is supplied it typically renders its
// own copy/download chrome (see CodeBlockActions in
// markdown-text.tsx). We only surface the fallback Copy button
// when we are using the plain <pre> fallback below.
// Custom code views ship their own copy/download chrome
// (CodeBlockActions in markdown-text.tsx); only show Copy
// when we are using the plain <pre> fallback.
<CopyButton source={source} />
) : null}
{activeTab === "preview" && language === "html" ? (
@ -631,10 +577,8 @@ 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.
// Open fence: no closing ``` yet. Lets HtmlSvgRenderer mount mid-stream
// (otherwise the markdown pipeline falls through until the close arrives).
const OPEN_CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*)$/;
export function parseCodeFence(blockContent: string): CodeFenceInfo | null {
@ -652,8 +596,7 @@ export function parseIncompleteCodeFence(
): 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.
// Strip a trailing partial ``` so mid-close captures do not leak it.
const body = match[2].replace(/\r?\n?```\s*$/, "");
return {
language: match[1]?.trim() || null,

View file

@ -222,12 +222,8 @@ function renderHighlightedCode(props: BlockProps, codeFence: CodeFenceInfo) {
function StreamdownBlock(props: BlockProps) {
const hasMermaidFence = props.content.includes("```mermaid");
const mermaidSource = getMermaidSource(props.content);
// parseCodeFence requires a closing ```; we fall back to
// parseIncompleteCodeFence both while the fence is still streaming AND
// when a finished reply forgot to emit the closing ``` (small local LLMs
// routinely drop it). Without the second fallback the HtmlSvgRenderer
// never mounts on an unclosed final message and the reply degrades to a
// plain code block.
// Fall back to parseIncompleteCodeFence for both still-streaming AND
// finished-but-unclosed fences (small LLMs routinely drop the closing ```).
const codeFence =
parseCodeFence(props.content) ?? parseIncompleteCodeFence(props.content);
@ -252,9 +248,7 @@ function StreamdownBlock(props: BlockProps) {
const svg = isSvgFence(codeFence);
const html = !svg && isHtmlFence(codeFence);
if (svg || html) {
// The HtmlSvgRenderer hosts a Code/Preview tab switcher and forces
// the Code tab while the fence is still streaming in so users see
// partial tokens rather than a flashing preview.
// Tabbed Preview/Code; locks to Code while streaming.
return (
<HtmlSvgRenderer
language={svg ? "svg" : "html"}

View file

@ -4,9 +4,7 @@
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// jsdom does not implement ResizeObserver; the HTML preview iframe uses one
// inside its srcDoc but the parent component also references it indirectly
// through DOM listeners. A no-op shim is enough for tests.
// jsdom shims: ResizeObserver + URL.createObjectURL.
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
@ -18,11 +16,6 @@ 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) =>