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); + }); +});