Studio: stop leaking the auth token through HTML canvas preview frames (#6634)

* Studio: stop leaking the auth token through HTML canvas preview frames

The artifact preview frame placed the Studio bearer token in the iframe URL
(?token=) whenever canvas network access was enabled. Untrusted canvas HTML
runs in that frame and can read its own window.location.href, and the
network-mode CSP allows outbound http/https, so the token could be
exfiltrated and replayed against authenticated Studio APIs. The auto-render
HTML cards widened the reach: ordinary or prompt-injected assistant html
fences become a Preview card that opens this same frame, and the render_html
tool path auto-opens it without a click.

Root cause: never put the token in the frame URL. The preview shell is a
static document that only renders HTML posted to it by its embedder, and
frame-ancestors plus the no-same-origin sandbox already constrain it, so the
endpoint no longer accepts or validates the token and selects the network
CSP from allow_network alone. No credential ever reaches the frame.

Defense in depth: only tool-rendered canvases may opt into network mode;
fences auto-extracted from assistant text never do.

* Studio: stop strict canvas frames from self-upgrading to network mode

Network mode is selected from the allow_network query param alone, so untrusted
canvas code in a strict frame could navigate its own iframe to
?allow_network=1; the frame's onLoad handler then reposted the same untrusted
HTML into the now network-enabled frame, giving a no-network or fenced canvas
unauthorized network egress.

Only inject the artifact for loads we initiated (mount or a src change), tracked
by a pending flag set when src changes. A self-navigation also fires onLoad but
is no longer fed, so the upgraded frame stays the inert shell. The strict CSP
default-src 'none' already blocks the child-iframe variant.

* Studio: trim comments in the canvas artifact security fix

Condense the added explanatory comments and the artifact-preview-frame docstring
to one line each while keeping the security rationale. No code change (verified
comment-only).
This commit is contained in:
Daniel Han 2026-06-25 05:25:06 -07:00 committed by GitHub
commit 8ca09b86dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 23 additions and 17 deletions

View file

@ -1234,15 +1234,13 @@ async def _authenticate_header_or_query(request: Request, token: Optional[str])
@studio_router.get("/artifact-preview-frame", include_in_schema = False)
async def artifact_preview_frame(
request: Request,
allow_network: bool = False,
token: Optional[str] = None,
):
"""Serve the opaque sandbox shell used for client-side HTML canvases."""
async def artifact_preview_frame(allow_network: bool = False):
"""Serve the opaque sandbox shell for client-side HTML canvases.
if allow_network:
await _authenticate_header_or_query(request, token)
No auth token by design: the URL is readable by the untrusted canvas via
location.href, and this static shell exposes no server resource (frame-ancestors
plus the sandbox already gate it), so the CSP is chosen from allow_network alone.
"""
csp = (
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP if allow_network else _ARTIFACT_PREVIEW_FRAME_STRICT_CSP

View file

@ -329,6 +329,8 @@ export function ArtifactSurface({
code={artifact.code}
title={artifact.title}
fill={true}
// Network mode only for tool-rendered canvases, never fences.
allowNetworkAccess={artifact.source === "tool"}
className="h-full"
/>
) : (

View file

@ -3,7 +3,6 @@
"use client";
import { getAuthToken } from "@/features/auth";
import { apiUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -36,30 +35,37 @@ export function ArtifactHtmlFrame({
title = "HTML canvas preview",
className,
fill = false,
// Tool-rendered canvases only; default off so fences never get network.
allowNetworkAccess = false,
}: {
code: string;
title?: string;
className?: string;
fill?: boolean;
allowNetworkAccess?: boolean;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const allowNetworkAccess = useChatRuntimeStore(
const networkAccessEnabled = useChatRuntimeStore(
(state) => state.allowArtifactNetworkAccess,
);
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
const src = useMemo(() => {
const query = new URLSearchParams({ v: hashArtifactCode(code) });
if (allowNetworkAccess) {
const token = getAuthToken();
if (token) {
query.set("allow_network", "1");
query.set("token", token);
}
// Never put the auth token in the URL: in-frame code can read location.href.
if (allowNetworkAccess && networkAccessEnabled) {
query.set("allow_network", "1");
}
return apiUrl(`/api/inference/artifact-preview-frame?${query.toString()}`);
}, [allowNetworkAccess, code]);
}, [allowNetworkAccess, networkAccessEnabled, code]);
// Feed only parent-initiated loads, so a self-navigated frame can't self-upgrade.
const pendingPostRef = useRef(false);
useEffect(() => {
pendingPostRef.current = true;
}, [src]);
const postArtifactHtml = useCallback(() => {
if (!pendingPostRef.current) return;
pendingPostRef.current = false;
// Sandboxed frame has an opaque origin ("null"), so a wildcard target is
// required; the payload only reaches this iframe's contentWindow.
iframeRef.current?.contentWindow?.postMessage(