Studio: serve HTML preview from a same-origin route so inline scripts run
Closes the long-documented follow-up. Inline <script> and onclick handlers
inside the assistant's ```html fence were dead under the previous srcdoc
path because Chromium inherits the embedder CSP (script-src 'self') for
srcdoc / data: / blob: iframes per HTML / CSP3. The only browser-supported
escape is a same-origin URL whose response headers carry an overriding CSP.
Backend: new POST /api/preview/html stashes the source for 10 min behind a
192-bit random token; GET /api/preview/html/{id} serves the snippet with
default-src 'none' + script-src 'unsafe-inline' + frame-ancestors 'self' +
X-Frame-Options SAMEORIGIN so the host chat page can iframe it but third
parties cannot. The GET is intentionally unauthenticated because browsers
do not attach Authorization to iframe subresource loads -- the unguessable
URL token is the authorisation. Eviction caps the in-memory store at 256
entries per worker; TTL sweep runs on each access.
Frontend: HtmlPreview now POSTs the source on mount, holds about:blank
until the URL arrives, then sets iframe src to the returned path. The
iframe sandbox stays "allow-scripts allow-modals allow-popups" with NO
allow-same-origin / allow-top-navigation, so even though the URL is
same-origin the iframe document is treated as a unique opaque origin
(cannot reach parent storage / DOM, cannot navigate the host page).
A srcdoc fallback kicks in if the POST fails so the layout still renders.
Tests:
* 9 new backend cases pin auth gating on POST, the unauth GET path,
CSP shape, X-Frame-Options override, TTL expiry, oldest-first eviction,
and per-call token uniqueness.
* Frontend vitest mocks the fetch round-trip; two existing tests rewritten
to await data-preview-state=ready, plus a new failing-fetch case that
exercises the srcdoc fallback (so a future regression there is loud).
Updates the in-host-CSP comment in main.py to reflect that the
"same-origin backend route" follow-up is now landed.
This commit is contained in:
parent
2cc2e5cfe6
commit
5171bcc991
6 changed files with 619 additions and 86 deletions
|
|
@ -118,6 +118,7 @@ from routes import (
|
|||
data_recipe_router,
|
||||
datasets_router,
|
||||
export_router,
|
||||
html_preview_router,
|
||||
inference_router,
|
||||
inference_studio_router,
|
||||
models_router,
|
||||
|
|
@ -327,16 +328,14 @@ 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 fetch), so allowing
|
||||
# data: / blob: here would not unlock interactive scripts
|
||||
# anyway -- Chromium inherits the embedder CSP for srcdoc,
|
||||
# data:, AND blob: iframes per HTML / CSP3, so the only way
|
||||
# to escape ``script-src 'self'`` for the preview would be a
|
||||
# same-origin backend route serving with overriding response
|
||||
# CSP headers. Tracked as a follow-up; for now the explicit
|
||||
# ``'self'`` setting leaves a visible directive that grep
|
||||
# picks up if a future change tries to relax it.
|
||||
# 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.
|
||||
"frame-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"form-action 'self'; "
|
||||
|
|
@ -539,6 +538,9 @@ app.include_router(export_router, prefix = "/api/export", tags = ["export"])
|
|||
app.include_router(
|
||||
training_history_router, prefix = "/api/train", tags = ["training-history"]
|
||||
)
|
||||
app.include_router(
|
||||
html_preview_router, prefix = "/api/preview/html", tags = ["html-preview"]
|
||||
)
|
||||
|
||||
|
||||
# ============ Health and System Endpoints ============
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from routes.export import router as export_router
|
|||
from routes.training_history import router as training_history_router
|
||||
from routes.chat_history import router as chat_history_router
|
||||
from routes.providers import router as providers_router
|
||||
from routes.html_preview import router as html_preview_router
|
||||
|
||||
__all__ = [
|
||||
"training_router",
|
||||
|
|
@ -29,4 +30,5 @@ __all__ = [
|
|||
"training_history_router",
|
||||
"chat_history_router",
|
||||
"providers_router",
|
||||
"html_preview_router",
|
||||
]
|
||||
|
|
|
|||
206
studio/backend/routes/html_preview.py
Normal file
206
studio/backend/routes/html_preview.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
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_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
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
|
||||
|
||||
|
||||
_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]
|
||||
for k in expired:
|
||||
_PREVIEWS.pop(k, 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).
|
||||
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.
|
||||
"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.
|
||||
"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'",
|
||||
# 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.
|
||||
"frame-ancestors 'self'",
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / response models.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HtmlPreviewCreate(BaseModel):
|
||||
source: str = Field(..., max_length = MAX_HTML_PREVIEW_BYTES)
|
||||
|
||||
|
||||
class HtmlPreviewCreateResponse(BaseModel):
|
||||
url: str
|
||||
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.
|
||||
"""
|
||||
_sweep_expired()
|
||||
source = payload.source
|
||||
if not isinstance(source, str): # defensive; pydantic enforces str already
|
||||
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")
|
||||
token = secrets.token_urlsafe(24)
|
||||
_PREVIEWS[token] = (time.monotonic(), source)
|
||||
_evict_overflow()
|
||||
return HtmlPreviewCreateResponse(
|
||||
url = f"/api/preview/html/{token}",
|
||||
expires_in_seconds = PREVIEW_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
_sweep_expired()
|
||||
item = _PREVIEWS.get(preview_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code = 404, detail = "Preview expired or not found")
|
||||
_, source = item
|
||||
return HTMLResponse(
|
||||
content = _build_html_doc(source),
|
||||
headers = {
|
||||
"Content-Security-Policy": _PREVIEW_CSP,
|
||||
"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.
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
},
|
||||
)
|
||||
199
studio/backend/tests/test_html_preview.py
Normal file
199
studio/backend/tests/test_html_preview.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Tests for the /api/preview/html route (interactive HTML preview)."""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def preview_app(tmp_path, monkeypatch):
|
||||
"""Standalone app mounting only the html-preview router on a clean store."""
|
||||
from auth import storage
|
||||
from auth.authentication import create_access_token
|
||||
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
|
||||
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
||||
|
||||
import secrets as _secrets
|
||||
storage.create_initial_user(
|
||||
username = storage.DEFAULT_ADMIN_USERNAME,
|
||||
password = "human-password-123",
|
||||
jwt_secret = _secrets.token_urlsafe(64),
|
||||
must_change_password = False,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
html_preview_router, prefix = "/api/preview/html", tags = ["html-preview"]
|
||||
)
|
||||
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
|
||||
return app, token, html_preview_module
|
||||
|
||||
|
||||
class TestPostHtmlPreview:
|
||||
def test_post_requires_auth(self, preview_app):
|
||||
app, _token, _mod = preview_app
|
||||
c = TestClient(app)
|
||||
r = c.post("/api/preview/html", json = {"source": "<h1>hi</h1>"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
def test_post_returns_same_origin_url_and_ttl(self, preview_app):
|
||||
app, token, _mod = preview_app
|
||||
c = TestClient(app)
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": "<h1>hello</h1>"},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["url"].startswith("/api/preview/html/")
|
||||
assert isinstance(body["expires_in_seconds"], int)
|
||||
assert body["expires_in_seconds"] > 0
|
||||
|
||||
def test_post_rejects_oversize_body(self, preview_app):
|
||||
app, token, mod = preview_app
|
||||
c = TestClient(app)
|
||||
too_big = "x" * (mod.MAX_HTML_PREVIEW_BYTES + 1)
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
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.
|
||||
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.
|
||||
app, token, _mod = preview_app
|
||||
c = TestClient(app)
|
||||
urls = set()
|
||||
for _ in range(5):
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": "<p>same</p>"},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
urls.add(r.json()["url"])
|
||||
assert len(urls) == 5
|
||||
|
||||
|
||||
class TestGetHtmlPreview:
|
||||
def _create(self, app, token, source):
|
||||
c = TestClient(app)
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": source},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
return r.json()["url"]
|
||||
|
||||
def test_get_serves_stored_html_with_overriding_csp(self, preview_app):
|
||||
app, token, _mod = preview_app
|
||||
url = self._create(app, token, "<button onclick=\"alert('x')\">go</button>")
|
||||
c = TestClient(app)
|
||||
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
|
||||
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
|
||||
assert "'self'" in directives["frame-ancestors"]
|
||||
# X-Frame-Options is SAMEORIGIN so the host page 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.
|
||||
app, token, _mod = preview_app
|
||||
url = self._create(app, token, "<p>nope</p>")
|
||||
c = TestClient(app)
|
||||
r = c.get(url) # no Authorization header
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_get_unknown_token_is_404(self, preview_app):
|
||||
app, _token, _mod = preview_app
|
||||
c = TestClient(app)
|
||||
r = c.get("/api/preview/html/totally-not-a-real-token")
|
||||
assert r.status_code == 404
|
||||
|
||||
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.
|
||||
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.
|
||||
assert token_id not in mod._PREVIEWS
|
||||
|
||||
|
||||
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
|
||||
urls = []
|
||||
for i in range(6):
|
||||
r = c.post(
|
||||
"/api/preview/html",
|
||||
json = {"source": f"<p>{i}</p>"},
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
urls.append(r.json()["url"])
|
||||
# Force monotonic progression so eviction order is deterministic.
|
||||
time.sleep(0.001)
|
||||
assert len(mod._PREVIEWS) == mod.MAX_LIVE_PREVIEWS
|
||||
# The two oldest tokens (urls[0], urls[1]) must have been evicted.
|
||||
for old in urls[:2]:
|
||||
token_id = old.rsplit("/", 1)[-1]
|
||||
assert token_id not in mod._PREVIEWS
|
||||
# Newer tokens are still present.
|
||||
for fresh in urls[-mod.MAX_LIVE_PREVIEWS:]:
|
||||
token_id = fresh.rsplit("/", 1)[-1]
|
||||
assert token_id in mod._PREVIEWS
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
HtmlSvgRenderer,
|
||||
isHtmlFence,
|
||||
|
|
@ -12,8 +12,37 @@ 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.
|
||||
let _previewIdCounter = 0;
|
||||
function installFetchStub(): void {
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (typeof input === "string" && input === "/api/preview/html") {
|
||||
const url = `/api/preview/html/test-token-${++_previewIdCounter}`;
|
||||
return new Response(JSON.stringify({ url }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
function installFailingFetchStub(): void {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response("server boom", { status: 500 }),
|
||||
) as typeof fetch;
|
||||
}
|
||||
|
||||
describe("HtmlSvgRenderer", () => {
|
||||
it("renders an HTML preview inside a sandboxed iframe by default", () => {
|
||||
beforeEach(() => {
|
||||
installFetchStub();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders an HTML preview inside a sandboxed iframe pointing at the same-origin preview route", async () => {
|
||||
const html = "<html><body><h1>hello</h1></body></html>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
|
||||
|
|
@ -25,14 +54,16 @@ describe("HtmlSvgRenderer", () => {
|
|||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
expect(iframe.tagName).toBe("IFRAME");
|
||||
// SECURITY: allow-scripts + allow-modals leave script / alert /
|
||||
// confirm operative if the inherited host CSP ever permits inline.
|
||||
// allow-popups lets ``<base target="_blank">`` links open without
|
||||
// being silently dropped, BUT the popups INHERIT the sandbox
|
||||
// (allow-popups-to-escape-sandbox is intentionally absent) so a
|
||||
// tab opened from a malicious assistant link cannot use
|
||||
// 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.
|
||||
// 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.
|
||||
const sandbox = iframe.getAttribute("sandbox") ?? "";
|
||||
const sandboxTokens = sandbox.split(/\s+/);
|
||||
expect(sandboxTokens).toContain("allow-scripts");
|
||||
|
|
@ -41,17 +72,34 @@ describe("HtmlSvgRenderer", () => {
|
|||
expect(sandboxTokens).not.toContain("allow-popups-to-escape-sandbox");
|
||||
expect(sandbox).not.toContain("allow-same-origin");
|
||||
expect(sandbox).not.toContain("allow-top-navigation");
|
||||
// srcdoc carries the assistant HTML plus a defense-in-depth meta
|
||||
// CSP (connect-src 'none', frame-src 'none', img-src data: blob:
|
||||
// only). Inline <script> / on* handlers do NOT execute today
|
||||
// because Chromium inherits the host CSP for srcdoc iframes
|
||||
// (also for blob: and data: -- empirically reproduced) and the
|
||||
// host enforces ``script-src 'self'``. The preview is for layout
|
||||
// / styles / images / source viewing; interactive demos are a
|
||||
// documented follow-up that needs a same-origin backend route.
|
||||
expect(iframe.getAttribute("src")).toBeNull();
|
||||
|
||||
// While the preview API call is in-flight the iframe holds
|
||||
// about:blank rather than flashing the 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.
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
|
||||
});
|
||||
const src = iframe.getAttribute("src") ?? "";
|
||||
expect(src.startsWith("/api/preview/html/")).toBe(true);
|
||||
expect(iframe.getAttribute("srcdoc")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to srcdoc with the defense-in-depth meta CSP when the preview API is unreachable", async () => {
|
||||
installFailingFetchStub();
|
||||
const html = "<html><body><h1>fallback</h1></body></html>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("error");
|
||||
});
|
||||
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc) ?? "";
|
||||
expect(srcdoc).toContain("hello");
|
||||
expect(srcdoc).toContain("<h1>fallback</h1>");
|
||||
expect(srcdoc).toContain('http-equiv="Content-Security-Policy"');
|
||||
expect(srcdoc).toContain("connect-src 'none'");
|
||||
expect(srcdoc).toContain("frame-src 'none'");
|
||||
|
|
@ -83,7 +131,7 @@ describe("HtmlSvgRenderer", () => {
|
|||
expect(srcdoc).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("toggles between Preview and Code tabs", () => {
|
||||
it("toggles between Preview and Code tabs", async () => {
|
||||
const html = "<html><body>hi</body></html>";
|
||||
render(
|
||||
<HtmlSvgRenderer
|
||||
|
|
@ -94,8 +142,16 @@ describe("HtmlSvgRenderer", () => {
|
|||
);
|
||||
|
||||
// Default tab is preview.
|
||||
expect(screen.getByTestId("html-svg-renderer-iframe")).toBeTruthy();
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) 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.
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
|
||||
});
|
||||
|
||||
const codeTab = screen.getByRole("tab", { name: /code/i });
|
||||
act(() => {
|
||||
|
|
@ -130,27 +186,42 @@ describe("HtmlSvgRenderer", () => {
|
|||
expect(previewTab.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("srcdoc payload carries the defense-in-depth meta CSP", () => {
|
||||
it("HtmlPreview POSTs the assistant source to /api/preview/html before mounting the iframe src", async () => {
|
||||
const html = "<button onclick=\"alert('x')\">go</button>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
const doc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc) ?? "";
|
||||
expect(doc).toContain("<button");
|
||||
expect(doc).toContain('http-equiv="Content-Security-Policy"');
|
||||
// Network egress is blocked regardless of script execution.
|
||||
expect(doc).toContain("connect-src 'none'");
|
||||
expect(doc).toContain("frame-src 'none'");
|
||||
// ``<base target="_blank">`` keeps link clicks from replacing the
|
||||
// iframe content with a navigation away from the preview.
|
||||
expect(doc).toContain('<base target="_blank">');
|
||||
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];
|
||||
expect(url).toBe("/api/preview/html");
|
||||
expect(init.method).toBe("POST");
|
||||
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.
|
||||
const src = iframe.getAttribute("src") ?? "";
|
||||
expect(src.startsWith("/api/preview/html/")).toBe(true);
|
||||
expect(iframe.getAttribute("srcdoc")).toBeNull();
|
||||
});
|
||||
|
||||
it("wires tabs to their panels with aria-controls / aria-labelledby", () => {
|
||||
it("wires tabs to their panels with aria-controls / aria-labelledby", async () => {
|
||||
const html = "<html><body>hi</body></html>";
|
||||
render(<HtmlSvgRenderer language="html" source={html} />);
|
||||
|
||||
const iframe = screen.getByTestId(
|
||||
"html-svg-renderer-iframe",
|
||||
) as HTMLIFrameElement;
|
||||
await waitFor(() => {
|
||||
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
|
||||
});
|
||||
|
||||
const previewTab = screen.getByRole("tab", { name: /preview/i });
|
||||
const codeTab = screen.getByRole("tab", { name: /code/i });
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
|
|
|
|||
|
|
@ -260,17 +260,11 @@ function SvgPreview({ source }: { source: string }) {
|
|||
const HTML_PREVIEW_HEIGHT_REPORTER =
|
||||
'<script>(()=>{const post=()=>parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");window.addEventListener("load",post);new ResizeObserver(post).observe(document.documentElement);})();</script>';
|
||||
|
||||
// Meta-CSP enforced INSIDE the srcdoc iframe. Chromium inherits the
|
||||
// embedder CSP into srcdoc, data:, AND blob: iframes per HTML / CSP3
|
||||
// § initialize-document-csp, so the host Studio ``script-src 'self'``
|
||||
// already blocks assistant inline scripts and on* handlers here --
|
||||
// confirmed empirically on the live Studio with a click-to-alert demo.
|
||||
// Until a same-origin backend route is added (response-header CSPs
|
||||
// do NOT inherit), the preview deliberately ships as a static-render
|
||||
// surface. The meta-CSP below is defense in depth: it adds
|
||||
// ``connect-src 'none'`` + ``frame-src 'none'`` so even if the host
|
||||
// CSP ever loosens enough to let inline scripts run, the preview
|
||||
// still cannot beacon out or nest tracking iframes.
|
||||
// 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.
|
||||
const HTML_IFRAME_CSP = [
|
||||
"default-src 'none'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
|
|
@ -290,14 +284,36 @@ 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 navigating the
|
||||
// sandboxed frame itself away from the preview.
|
||||
'<base target="_blank">',
|
||||
source,
|
||||
HTML_PREVIEW_HEIGHT_REPORTER,
|
||||
].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``.
|
||||
const HTML_PREVIEW_API = "/api/preview/html";
|
||||
|
||||
function getStoredAccessToken(): string | null {
|
||||
// Mirrors the bearer storage used by features/auth/. We avoid the import
|
||||
// cycle by reading sessionStorage / localStorage directly.
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return (
|
||||
window.sessionStorage.getItem("unsloth.access_token") ??
|
||||
window.localStorage.getItem("unsloth.access_token")
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PreviewState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; url: string }
|
||||
| { kind: "error" };
|
||||
|
||||
function HtmlPreview({
|
||||
source,
|
||||
popped,
|
||||
|
|
@ -308,24 +324,57 @@ function HtmlPreview({
|
|||
onHeightChange?: (h: number | null) => void;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
// srcdoc, blob:, and data: all inherit the host CSP in Chromium, so
|
||||
// the choice between them does not affect script execution today.
|
||||
// srcdoc is the simplest and avoids URL.createObjectURL churn, so
|
||||
// that is what we use. Inline <script> / on* handlers in the
|
||||
// assistant HTML do NOT execute under the current host CSP; the
|
||||
// preview is for layout, images, and styles. The auto-height
|
||||
// postMessage reporter is appended for the future state where the
|
||||
// host CSP is relaxed via a backend-served preview route.
|
||||
const srcDoc = useMemo(() => buildHtmlSrcDoc(source), [source]);
|
||||
// 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).
|
||||
const [previewState, setPreviewState] = useState<PreviewState>({
|
||||
kind: "loading",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPreviewState({ kind: "loading" });
|
||||
onHeightChange?.(null);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const token = getStoredAccessToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
void fetch(HTML_PREVIEW_API, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers,
|
||||
body: JSON.stringify({ source }),
|
||||
})
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTML preview HTTP ${r.status}`);
|
||||
return (await r.json()) as { url: string };
|
||||
})
|
||||
.then(({ url }) => {
|
||||
if (cancelled) return;
|
||||
if (typeof url !== "string" || !url.startsWith("/api/preview/html/")) {
|
||||
throw new Error("HTML preview returned an unexpected URL shape");
|
||||
}
|
||||
setPreviewState({ kind: "ready", url });
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setPreviewState({ kind: "error" });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [source, onHeightChange]);
|
||||
|
||||
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]);
|
||||
}, [previewState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
|
|
@ -342,35 +391,39 @@ function HtmlPreview({
|
|||
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.
|
||||
const iframeHeight = popped
|
||||
? "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.
|
||||
const errorSrcDoc = useMemo(
|
||||
() => (previewState.kind === "error" ? buildHtmlSrcDoc(source) : null),
|
||||
[previewState.kind, source],
|
||||
);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
data-testid="html-svg-renderer-iframe"
|
||||
data-preview-state={previewState.kind}
|
||||
title="HTML preview"
|
||||
srcDoc={srcDoc}
|
||||
src={previewState.kind === "ready" ? previewState.url : "about:blank"}
|
||||
srcDoc={errorSrcDoc ?? undefined}
|
||||
// SECURITY:
|
||||
// allow-scripts -- ready for the day the host CSP
|
||||
// gives the preview a script-src
|
||||
// that includes 'unsafe-inline'
|
||||
// allow-modals -- alert/confirm/prompt are not no-ops
|
||||
// when scripts do fire
|
||||
// allow-popups -- the ``<base target="_blank">`` link
|
||||
// rule can open a new tab instead of
|
||||
// silently dropping the click
|
||||
// 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-same-origin / allow-top-navigation -- the iframe
|
||||
// cannot read parent.document or navigate the host page
|
||||
// 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. The opened tab loads with an
|
||||
// opaque origin (some sites will render degraded) which is
|
||||
// the deliberate trade-off for tabnabbing safety.
|
||||
// to tabnab the Studio tab
|
||||
sandbox="allow-scripts allow-modals allow-popups"
|
||||
style={{
|
||||
width: "100%",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue