From a4cc322a55b1b6cc59e0e5b3b81aff636448b758 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 24 Jun 2026 18:21:16 +0000 Subject: [PATCH] feat: apply status page CSP to custom domains Detect custom domains by comparing the request host to the app host and apply the tightened CSP there too, not just on the public path. Run the middleware before express.static so static-served root documents get the header, and keep helmet after static so its default-src does not block custom domain API calls. --- .../api/middleware/statusPageDocumentCsp.ts | 22 +++++++--- server/src/app.ts | 5 ++- .../middleware/statusPageDocumentCsp.test.ts | 44 ++++++++++++++----- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts index 979f78791..c5175b81d 100644 --- a/server/src/api/middleware/statusPageDocumentCsp.ts +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -1,9 +1,10 @@ import type { NextFunction, Request, Response } from "express"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; 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, +// Browsers enforce the intersection of all CSP headers, so this only tightens +// the status page on top of the global helmet policy, blocking external images, // fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. const STATUS_PAGE_CSP = [ "img-src 'self' data:", @@ -11,9 +12,16 @@ const STATUS_PAGE_CSP = [ "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(); +// A status page document is served on the public path or on a custom domain +// (any host other than the app's own). Mirrors the client isCustomDomainHost. +export const createStatusPageDocumentCsp = (clientHost: string) => { + const appHostname = normalizeStatusPageDomain(clientHost); + + return (req: Request, res: Response, next: NextFunction) => { + const onCustomDomain = appHostname !== null && normalizeStatusPageDomain(req.hostname) !== appHostname; + if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX) || onCustomDomain) { + res.append("Content-Security-Policy", STATUS_PAGE_CSP); + } + return next(); + }; }; diff --git a/server/src/app.ts b/server/src/app.ts index b79f71250..6c9ba03bc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,7 +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"; +import { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; export const createApp = ({ services, @@ -49,6 +49,8 @@ export const createApp = ({ return cors(corsOptions)(req, res, next); }); + app.use(createStatusPageDocumentCsp(allowedOrigin)); + app.use(express.static(frontendPath)); app.use(express.json()); @@ -71,7 +73,6 @@ 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 index 450728bfc..ab0c20abd 100644 --- a/server/test/unit/middleware/statusPageDocumentCsp.test.ts +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; import type { NextFunction, Request, Response } from "express"; -import { statusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; +import { createStatusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; const makeRes = (): Response => { const res = {} as Response; @@ -8,25 +8,47 @@ const makeRes = (): Response => { return res; }; +const cspMiddleware = createStatusPageDocumentCsp("http://localhost:10001"); + +const run = (req: Partial) => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + cspMiddleware(req as Request, res, next); + return { res, next }; +}; + +const appendedValue = (res: Response): string => ((res.append as jest.Mock).mock.calls[0] as [string, string])[1]; + 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); + it("appends a tightened CSP on the public status page path", () => { + const { res, next } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); 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"); + const value = appendedValue(res); 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); + it("does not restrict default-src or connect-src so the page can still reach the API", () => { + const { res } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); + + const value = appendedValue(res); + expect(value).not.toContain("default-src"); + expect(value).not.toContain("connect-src"); + }); + + it("appends the CSP on a custom domain document at the host root", () => { + const { res, next } = run({ path: "/", hostname: "status.example.com" }); + + expect(res.append).toHaveBeenCalledTimes(1); + expect(appendedValue(res)).toContain("img-src 'self' data:"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves the app's own non-public routes untouched", () => { + const { res, next } = run({ path: "/status/create", hostname: "localhost" }); expect(res.append).not.toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(1);