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:
parent
1cb04be328
commit
8ca09b86dc
3 changed files with 23 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue