From ffbf2232882d30e53b76d1f6dfff0e84864069b1 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Tue, 23 Jun 2026 19:25:40 +0000 Subject: [PATCH] feat: tighten CSP on the public status page document Layer a stricter Content-Security-Policy on the /status/public document so custom CSS cannot load external images, fonts, or stylesheets. Browsers enforce the intersection of all CSP headers, so this only narrows the public status page and never loosens the global policy, while keeping the app's Google Fonts. Browser-enforced backstop behind the validator. --- .../api/middleware/statusPageDocumentCsp.ts | 19 +++++++++++ server/src/app.ts | 2 ++ .../middleware/statusPageDocumentCsp.test.ts | 34 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 server/src/api/middleware/statusPageDocumentCsp.ts create mode 100644 server/test/unit/middleware/statusPageDocumentCsp.test.ts diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts new file mode 100644 index 000000000..979f78791 --- /dev/null +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -0,0 +1,19 @@ +import type { NextFunction, Request, Response } from "express"; + +const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; + +// Browsers enforce the intersection of all CSP headers, so this only tightens the +// public status page on top of the global helmet policy: it blocks external images, +// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. +const STATUS_PAGE_CSP = [ + "img-src 'self' data:", + "font-src 'self' data: https://fonts.gstatic.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", +].join("; "); + +export const statusPageDocumentCsp = (req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX)) { + res.append("Content-Security-Policy", STATUS_PAGE_CSP); + } + return next(); +}; diff --git a/server/src/app.ts b/server/src/app.ts index 907c0f77d..b79f71250 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,6 +14,7 @@ import { InitializedControllers } from "@/config/controllers.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; +import { statusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; export const createApp = ({ services, @@ -70,6 +71,7 @@ export const createApp = ({ }, }) ); + app.use(statusPageDocumentCsp); app.use( compression({ level: 6, diff --git a/server/test/unit/middleware/statusPageDocumentCsp.test.ts b/server/test/unit/middleware/statusPageDocumentCsp.test.ts new file mode 100644 index 000000000..450728bfc --- /dev/null +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { statusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; + +const makeRes = (): Response => { + const res = {} as Response; + res.append = jest.fn().mockReturnValue(res) as unknown as Response["append"]; + return res; +}; + +describe("statusPageDocumentCsp", () => { + it("appends a tightened CSP on the public status page document", () => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + statusPageDocumentCsp({ path: "/status/public/my-status-page" } as Request, res, next); + + expect(res.append).toHaveBeenCalledTimes(1); + const [header, value] = (res.append as jest.Mock).mock.calls[0] as [string, string]; + expect(header).toBe("Content-Security-Policy"); + expect(value).toContain("img-src 'self' data:"); + expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com"); + expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves other routes untouched", () => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + statusPageDocumentCsp({ path: "/status/create" } as Request, res, next); + + expect(res.append).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); +});