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 @@
11import { expect, it } from "@effect/vitest";
22import { describe } from "vite-plus/test";
33
4import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
4import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
55
66describe("http dev routing", () => {
77 it("treats localhost and loopback addresses as local", () => {
......@@ -26,3 +26,22 @@ describe("http dev routing", () => {
2626 );
2727 });
2828});
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
4343const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
4444const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
4545const 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
4658export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
4759 global: true,
4860});
......@@ -207,10 +219,7 @@ export const assetRouteLayer = HttpRouter.add(
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 );