diff --git a/studio/backend/main.py b/studio/backend/main.py
index 186422bc7f..be124bfe5e 100644
--- a/studio/backend/main.py
+++ b/studio/backend/main.py
@@ -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 ============
diff --git a/studio/backend/routes/__init__.py b/studio/backend/routes/__init__.py
index 6bb5d15e8e..46d33e1b15 100644
--- a/studio/backend/routes/__init__.py
+++ b/studio/backend/routes/__init__.py
@@ -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",
]
diff --git a/studio/backend/routes/html_preview.py b/studio/backend/routes/html_preview.py
new file mode 100644
index 0000000000..e17ae5de53
--- /dev/null
+++ b/studio/backend/routes/html_preview.py
@@ -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:
+ # ```` mirrors the srcdoc fallback so any ````
+ # without an explicit target opens in a new tab rather than navigating
+ # the iframe (which would be UX-confusing).
+ return (
+ ""
+ ''
+ + source
+ )
+
+
+_PREVIEW_CSP = "; ".join((
+ "default-src 'none'",
+ # ``script-src 'unsafe-inline'`` enables BOTH ``';
-// 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
+// ``