History

fix(server): sandbox user-provided SVGs (#5916)

Theo Browne committed 3 months ago

Commit 2abe6681 parent 0d388662 files · +33 −5Browse files

Unified · Split

2 files
  1. apps/server/src/http.test.ts +20 −1
  2. apps/server/src/http.ts +13 −4
apps/server/src/http.test.ts+20−1
@@ -1,7 +1,7 @@
11 import { expect, it } from "@effect/vitest";
22 import { describe } from "vite-plus/test";
33
4−import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
4+import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
55
66 describe("http dev routing", () => {
77 it("treats localhost and loopback addresses as local", () => {
@@ -26,3 +26,22 @@ describe("http dev routing", () => { · 18 unmodified lines
2626 );
2727 });
2828 });
29+
30+describe("assetResponseHeaders", () => {
31+ it("sandboxes SVG assets", () => {
32+ expect(assetResponseHeaders("/attachments/user-image.svg")).toMatchObject({
33+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
34+ "X-Content-Type-Options": "nosniff",
35+ });
36+ expect(assetResponseHeaders("/attachments/user-image.SVG")).toHaveProperty(
37+ "Content-Security-Policy",
38+ );
39+ });
40+
41+ it("does not apply document policy to raster images", () => {
42+ expect(assetResponseHeaders("/attachments/user-image.png")).toEqual({
43+ "Cache-Control": "private, max-age=3600",
44+ "X-Content-Type-Options": "nosniff",
45+ });
46+ });
47+});
apps/server/src/http.ts+13−4
@@ -43,6 +43,18 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht · 42 unmodified lines
4343 const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
4444 const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
4545 const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
46+const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
47+
48+export function assetResponseHeaders(filePath: string): Record<string, string> {
49+ return {
50+ "Cache-Control": "private, max-age=3600",
51+ "X-Content-Type-Options": "nosniff",
52+ ...(filePath.toLowerCase().endsWith(".svg")
53+ ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
54+ : {}),
55+ };
56+}
57+
4658 export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
4759 global: true,
4860 });
@@ -207,10 +219,7 @@ export const assetRouteLayer = HttpRouter.add( · 158 unmodified lines
207219 }
208220 return yield* HttpServerResponse.file(asset.path, {
209221 status: 200,
210− headers: {
211− "Cache-Control": "private, max-age=3600",
212− "X-Content-Type-Options": "nosniff",
213− },
222+ headers: assetResponseHeaders(asset.path),
214223 }).pipe(
215224 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
216225 );