authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-09 19:51:43-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-09 16:51:43-07:00
log2abe66800cceeafea759d4db782c72298cc1e1e1
tree9c1113fe93ca28f137817e9807d09d9cb7284c5f
parent0d38866dcf63d133b2ed732bbb303dc533b5934f
signature Signed by PGP key B5690EEEBB952194

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


2 files changed, 33 insertions(+), 5 deletions(-)

apps/server/src/http.test.ts+20-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { describe } from "vite-plus/test";2import { describe } from "vite-plus/test";
33
4import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";4import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
55
6describe("http dev routing", () => {6describe("http dev routing", () => {
7 it("treats localhost and loopback addresses as local", () => {7 it("treats localhost and loopback addresses as local", () => {
...@@ -26,3 +26,22 @@ describe("http dev routing", () => {...@@ -26,3 +26,22 @@ describe("http dev routing", () => {
26 );26 );
27 });27 });
28});28});
29
30describe("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...@@ -43,6 +43,18 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht
43const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";43const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
44const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);44const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
45const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];45const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
46const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
47
48export 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
46export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {58export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
47 global: true,59 global: true,
48});60});
...@@ -207,10 +219,7 @@ export const assetRouteLayer = HttpRouter.add(...@@ -207,10 +219,7 @@ export const assetRouteLayer = HttpRouter.add(
207 }219 }
208 return yield* HttpServerResponse.file(asset.path, {220 return yield* HttpServerResponse.file(asset.path, {
209 status: 200,221 status: 200,
210 headers: {222 headers: assetResponseHeaders(asset.path),
211 "Cache-Control": "private, max-age=3600",
212 "X-Content-Type-Options": "nosniff",
213 },
214 }).pipe(223 }).pipe(
215 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),224 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
216 );225 );