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(