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