From bace3a8f7bf53bccb93533b7743b937c6ccc37af Mon Sep 17 00:00:00 2001 From: Jeremy Eder Date: Tue, 21 Apr 2026 18:11:39 +0000 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20XSS=20hardening=20=E2=80=94=20?= =?UTF-8?q?SVG=20sanitization,=20iframe=20sandbox,=20workspace=20CSP=20(SE?= =?UTF-8?q?C-2sl.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SEC-2sl.1.1: Replace dangerouslySetInnerHTML SVG rendering with a blob URL approach via a new SvgBlobImage component, blocking all script execution in SVG files - SEC-2sl.1.2: Remove allow-same-origin from iframe sandbox attribute, preventing sandboxed HTML content from escaping the sandbox and accessing the parent origin - SEC-2sl.1.3: Add Content-Security-Policy: sandbox; script-src 'none' response header for HTML and SVG workspace files served on the same origin, blocking stored XSS Co-Authored-By: Claude Sonnet 4.6 --- .../workspace/[...path]/route.ts | 8 ++++++ .../src/components/file-content-viewer.tsx | 27 ++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) mode change 100644 => 100755 components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts mode change 100644 => 100755 components/frontend/src/components/file-content-viewer.tsx diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts old mode 100644 new mode 100755 index a61727e2d..b28fa3cb2 --- a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts @@ -34,6 +34,14 @@ export async function GET( responseHeaders['Content-Disposition'] = 'inline' } + // For HTML and SVG files served on the same origin, add a strict CSP to prevent script execution. + // The sandbox directive combined with script-src 'none' blocks all active content regardless of + // the file's own markup, protecting the parent app from stored XSS via workspace files. + const scriptBlockedExts = new Set(['html', 'htm', 'svg']) + if (scriptBlockedExts.has(ext)) { + responseHeaders['Content-Security-Policy'] = "sandbox; script-src 'none'" + } + return new Response(buf, { status: resp.status, headers: responseHeaders }) } diff --git a/components/frontend/src/components/file-content-viewer.tsx b/components/frontend/src/components/file-content-viewer.tsx old mode 100644 new mode 100755 index e787bed64..b7c31a19e --- a/components/frontend/src/components/file-content-viewer.tsx +++ b/components/frontend/src/components/file-content-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -75,6 +75,27 @@ function NumberedCodeBlock({ content, className }: { content: string; className? ); } +/** + * Renders an SVG string safely as an via a blob URL, blocking all script execution. + * The blob URL is revoked on unmount to avoid memory leaks. + */ +function SvgBlobImage({ content, fileName }: { content: string; fileName: string }) { + const [blobUrl, setBlobUrl] = useState(null); + + useEffect(() => { + const blob = new Blob([content], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + setBlobUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [content]); + + if (!blobUrl) return null; + // eslint-disable-next-line @next/next/no-img-element + return {fileName}; +} + /** Renders file content with type-specific viewers (image, PDF, HTML, markdown, binary, text). */ export function FileContentViewer({ fileName, content, fileUrl, fileSize: fileSizeProp, onDownload }: FileContentViewerProps) { const [imageError, setImageError] = useState(false); @@ -99,7 +120,7 @@ export function FileContentViewer({ fileName, content, fileUrl, fileSize: fileSi // eslint-disable-next-line @next/next/no-img-element {fileName} setImageError(true)} /> ) : fileInfo.mimeType === 'image/svg+xml' ? ( -
+ ) : (
@@ -169,7 +190,7 @@ export function FileContentViewer({ fileName, content, fileUrl, fileSize: fileSi {...(fileUrl ? { src: fileUrl } : { srcDoc: content })} className="w-full h-full bg-white" title={fileName} - sandbox="allow-scripts allow-same-origin" + sandbox="allow-scripts" />