From 9a7276ab2d9339f7203bdda6e74378589699f401 Mon Sep 17 00:00:00 2001 From: PJ Doland Date: Sun, 17 May 2026 23:14:44 -0400 Subject: [PATCH 1/2] fix(chat): inject strict CSP into HTMLFrame blob document ChatResponseHTMLFrame renders LLM/MCP-supplied HTML in an iframe with sandbox="allow-scripts" backed by a blob: URL. The blob URL has a null origin so cookies and parent DOM are unreachable, but allow-scripts without same-origin still permits fetch() to any URL the user's browser can reach: 169.254.169.254 cloud metadata, cluster intranet hosts, the Jupyter server's own API. A poisoned tool result could ship a script that exfils metadata or pivots SSRF against the JupyterHub pod network. Inject a Content-Security-Policy meta tag at the top of every HTMLFrame blob via a new src/html-frame-csp.ts helper. The policy keeps inline scripts and styles enabled (matplotlib, plotly offline mode, custom dashboards typically inline everything they need), allows only data: images and fonts, and zeroes out every network sink: connect-src, frame-src, child-src, worker-src, manifest-src, media-src, object-src, form-action, base-uri. The meta is always prepended rather than spliced into : an HTML-naive regex can be fooled by inside a comment or noscript, which would let the real run unguarded. Browsers fold a leading meta into the synthetic ahead of any author-supplied tag, so prepend is both simpler and safer. --- src/chat-sidebar.tsx | 6 ++- src/html-frame-csp.ts | 51 ++++++++++++++++++ tests/ts/html-frame-csp.test.ts | 96 +++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 src/html-frame-csp.ts create mode 100644 tests/ts/html-frame-csp.test.ts diff --git a/src/chat-sidebar.tsx b/src/chat-sidebar.tsx index dd4296ab..24626aee 100644 --- a/src/chat-sidebar.tsx +++ b/src/chat-sidebar.tsx @@ -74,6 +74,7 @@ import { isDarkTheme, writeTextToClipboard } from './utils'; +import { injectHtmlFrameCsp } from './html-frame-csp'; import { CheckBoxItem } from './components/checkbox'; import { mcpServerSettingsToEnabledState } from './components/mcp-util'; import claudeSvgStr from '../style/icons/claude.svg'; @@ -499,7 +500,10 @@ const answeredForms = new Map(); function ChatResponseHTMLFrame(props: any) { const iframSrc = useMemo( - () => URL.createObjectURL(new Blob([props.source], { type: 'text/html' })), + () => + URL.createObjectURL( + new Blob([injectHtmlFrameCsp(props.source)], { type: 'text/html' }) + ), [] ); return ( diff --git a/src/html-frame-csp.ts b/src/html-frame-csp.ts new file mode 100644 index 00000000..c4d820b5 --- /dev/null +++ b/src/html-frame-csp.ts @@ -0,0 +1,51 @@ +// Copyright (c) Mehmet Bektas + +/** + * Content-Security-Policy injected into every HTMLFrame blob document. + * + * The iframe already runs with sandbox="allow-scripts" (no allow-same-origin), + * so cookies and parent DOM are unreachable. But scripts in a null-origin + * context can still fetch() to any URL the user's browser can reach: + * cluster intranet, 169.254.169.254 cloud metadata, the Jupyter server + * itself. Inline scripts/styles stay enabled because most LLM/tool HTML + * output (matplotlib, plotly, custom dashboards) is self-contained inline; + * external CDN loads and any network egress are blocked. img/font are + * allowed only as data: URIs so inline visualizations still render. + */ +export const HTML_FRAME_CSP = [ + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + 'img-src data:', + 'font-src data:', + "connect-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-src 'none'", + "child-src 'none'", + "worker-src 'none'", + "manifest-src 'none'", + "media-src 'none'", + "object-src 'none'" +].join('; '); + +const CSP_META_TAG = ``; + +/** + * Prepend the CSP `` tag to untrusted HTML so the resulting blob + * document boots under policy. + * + * Always prepend rather than trying to locate ``: an HTML-naive + * regex can be fooled by `` inside a comment, `