From a4b59834312317f16d5e9d15cc5bd97044efbf63 Mon Sep 17 00:00:00 2001 From: theonejvo Date: Thu, 29 Jan 2026 00:44:42 +1100 Subject: [PATCH] fix: add CSP headers and Content-Disposition to prevent SVG XSS SVG files served via the skills and souls file endpoints can execute arbitrary JavaScript through , giving attackers access to localStorage tokens (including Convex auth JWTs and refresh tokens) on the same origin. PoC: https://clawdhub.com/api/v1/skills/red-pill/file?path=icon.svg This patch adds three layers of defense to both file-serving endpoints: - Content-Security-Policy: default-src 'none' blocks all script execution - X-Content-Type-Options: nosniff prevents MIME type sniffing - Content-Disposition: attachment on SVG files forces download instead of inline rendering --- convex/httpApiV1.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index 5590e67..29c1c26 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -375,6 +375,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) { if (!blob) return text('File missing in storage', 410, rate.headers) const textContent = await blob.text() + const isSvg = file.contentType?.toLowerCase().includes('svg') const headers = mergeHeaders(rate.headers, { 'Content-Type': file.contentType ? `${file.contentType}; charset=utf-8` @@ -383,6 +384,9 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) { ETag: file.sha256, 'X-Content-SHA256': file.sha256, 'X-Content-Size': String(file.size), + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src * data:; media-src *", + ...(isSvg ? { 'Content-Disposition': 'attachment' } : {}), }) return new Response(textContent, { status: 200, headers }) } @@ -984,6 +988,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) { void ctx.runMutation(api.soulDownloads.increment, { soulId: soulResult.soul._id }) + const isSvg = file.contentType?.toLowerCase().includes('svg') const headers = mergeHeaders(rate.headers, { 'Content-Type': file.contentType ? `${file.contentType}; charset=utf-8` @@ -992,6 +997,9 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) { ETag: file.sha256, 'X-Content-SHA256': file.sha256, 'X-Content-Size': String(file.size), + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src * data:; media-src *", + ...(isSvg ? { 'Content-Disposition': 'attachment' } : {}), }) return new Response(textContent, { status: 200, headers }) }