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 +// ``