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.
This commit is contained in:
Buco7854 committed 2026-06-24 18:21:16 +00:00
1 parent ffbf223288
commit a4cc322a55
3 files changed
+51 -20

No files matched your search

@@ -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();
};
};
+3 -2
View File
@@ -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,
@@ -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<Request>) => {
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);