From 8ca09b86dc5085880cb0d74a14b0df09d468d8ac Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 25 Jun 2026 05:25:06 -0700 Subject: [PATCH] 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). --- studio/backend/routes/inference.py | 14 +++++------ .../chat/artifacts/artifact-surface.tsx | 2 ++ .../features/chat/artifacts/html-frame.tsx | 24 ++++++++++++------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 8b0981cd2b..e77616fb9f 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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 diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx index 7c46ab11ba..0f4c83e0db 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx @@ -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" /> ) : ( diff --git a/studio/frontend/src/features/chat/artifacts/html-frame.tsx b/studio/frontend/src/features/chat/artifacts/html-frame.tsx index 6b18b27c09..b26f2f6685 100644 --- a/studio/frontend/src/features/chat/artifacts/html-frame.tsx +++ b/studio/frontend/src/features/chat/artifacts/html-frame.tsx @@ -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(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(