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.
This commit is contained in:
Buco7854 committed 2026-06-23 19:25:40 +00:00
1 parent 96021b7479
commit ffbf223288
3 files changed
+55

No files matched your search

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