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:
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();
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user