From 6d7cf14a4fb3fe6bfeb6acab45c440b03381c1f5 Mon Sep 17 00:00:00 2001 From: OpenClaw Subagent Date: Fri, 10 Apr 2026 14:11:30 +0200 Subject: [PATCH] security(csp): tighten font-src and style-src now that fonts are self-hosted --- src/__tests__/csp-headers.test.ts | 65 +++++++++++++++++++++++++++++++ src/index.ts | 16 +++++++- src/routes/pages.ts | 2 +- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/csp-headers.test.ts diff --git a/src/__tests__/csp-headers.test.ts b/src/__tests__/csp-headers.test.ts new file mode 100644 index 0000000..618e744 --- /dev/null +++ b/src/__tests__/csp-headers.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { app } from "../index.js"; + +/** + * Content-Security-Policy headers should be as tight as possible. + * + * Since self-hosting Inter (GDPR fix), there is no need to allow + * font-src 'self' https: data: + * style-src 'self' https: 'unsafe-inline' + * on either the default (helmet) CSP or the /docs custom CSP. + * + * - font-src should be "'self'" only + * - style-src should allow 'self' and 'unsafe-inline' (inline style="..." in HTML), + * but NOT https: + * - /docs must still allow 'unsafe-eval' in script-src for Swagger UI + */ +describe("Content-Security-Policy headers", () => { + describe("default CSP (helmet) on landing page", () => { + it("font-src is 'self' only (no https:, no data:)", async () => { + const res = await request(app).get("/"); + const csp = res.headers["content-security-policy"]; + expect(csp, "content-security-policy header missing").toBeDefined(); + expect(csp).toMatch(/font-src\s+'self'(?:\s*;|\s*$)/); + expect(csp).not.toMatch(/font-src[^;]*https:/); + expect(csp).not.toMatch(/font-src[^;]*\bdata:/); + }); + + it("style-src contains 'self' and 'unsafe-inline' but no https:", async () => { + const res = await request(app).get("/"); + const csp = res.headers["content-security-policy"]; + expect(csp).toBeDefined(); + expect(csp).toMatch(/style-src[^;]*'self'/); + expect(csp).toMatch(/style-src[^;]*'unsafe-inline'/); + expect(csp).not.toMatch(/style-src[^;]*https:/); + }); + }); + + describe("custom CSP on /docs (Swagger UI)", () => { + it("font-src is 'self' only", async () => { + const res = await request(app).get("/docs"); + const csp = res.headers["content-security-policy"]; + expect(csp, "content-security-policy header missing on /docs").toBeDefined(); + expect(csp).toMatch(/font-src\s+'self'(?:\s*;|\s*$)/); + expect(csp).not.toMatch(/font-src[^;]*https:/); + expect(csp).not.toMatch(/font-src[^;]*\bdata:/); + }); + + it("style-src contains 'self' and 'unsafe-inline' but no https:", async () => { + const res = await request(app).get("/docs"); + const csp = res.headers["content-security-policy"]; + expect(csp).toBeDefined(); + expect(csp).toMatch(/style-src[^;]*'self'/); + expect(csp).toMatch(/style-src[^;]*'unsafe-inline'/); + expect(csp).not.toMatch(/style-src[^;]*https:/); + }); + + it("script-src still contains 'unsafe-eval' (required by Swagger UI)", async () => { + const res = await request(app).get("/docs"); + const csp = res.headers["content-security-policy"]; + expect(csp).toBeDefined(); + expect(csp).toMatch(/script-src[^;]*'unsafe-eval'/); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 6ce32de..38570a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,21 @@ import { startPeriodicCleanup, stopPeriodicCleanup } from "./utils/periodic-clea const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); -app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); +// Content-Security-Policy: tightened now that Inter font is self-hosted. +// - font-src: 'self' only (no external CDNs, no data: URIs) +// - style-src: 'self' + 'unsafe-inline' (codebase uses inline style="..." attributes) +// - script-src: 'self' (default; /docs overrides to add 'unsafe-eval' for Swagger UI) +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + "font-src": ["'self'"], + "style-src": ["'self'", "'unsafe-inline'"], + }, + }, + crossOriginResourcePolicy: { policy: "cross-origin" }, + }) +); // Request ID + request logging middleware app.use((req, res, next) => { diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 2a77fed..2e903ac 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -28,7 +28,7 @@ pagesRouter.get("/openapi.json", (_req: Request, res: Response) => { pagesRouter.get("/docs", (_req: Request, res: Response) => { res.setHeader( "Content-Security-Policy", - "default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' https: 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' https: data:;connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'" + "default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' 'unsafe-inline';img-src 'self' data: blob:;font-src 'self';connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'" ); res.setHeader("Cache-Control", "public, max-age=86400"); res.sendFile(path.join(publicDir, "docs.html"));