diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 4af2ecb6457..ec4d2aae16e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "@effect/vitest"; import { describe } from "vite-plus/test"; -import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -26,3 +26,22 @@ describe("http dev routing", () => { ); }); }); + +describe("assetResponseHeaders", () => { + it("sandboxes SVG assets", () => { + expect(assetResponseHeaders("/attachments/user-image.svg")).toMatchObject({ + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", + "X-Content-Type-Options": "nosniff", + }); + expect(assetResponseHeaders("/attachments/user-image.SVG")).toHaveProperty( + "Content-Security-Policy", + ); + }); + + it("does not apply document policy to raster images", () => { + expect(assetResponseHeaders("/attachments/user-image.png")).toEqual({ + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 3c406731a4b..0da55686b92 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -43,6 +43,18 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +export function assetResponseHeaders(filePath: string): Record { + return { + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + ...(filePath.toLowerCase().endsWith(".svg") + ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } + : {}), + }; +} + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -207,10 +219,7 @@ export const assetRouteLayer = HttpRouter.add( } return yield* HttpServerResponse.file(asset.path, { status: 200, - headers: { - "Cache-Control": "private, max-age=3600", - "X-Content-Type-Options": "nosniff", - }, + headers: assetResponseHeaders(asset.path), }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), );