security(csp): tighten font-src and style-src now that fonts are self-hosted
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 20m47s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 20m47s
This commit is contained in:
parent
a374a93937
commit
6d7cf14a4f
3 changed files with 81 additions and 2 deletions
65
src/__tests__/csp-headers.test.ts
Normal file
65
src/__tests__/csp-headers.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import request from "supertest";
|
||||
import { app } from "../index.js";
|
||||
|
||||
/**
|
||||
* Content-Security-Policy headers should be as tight as possible.
|
||||
*
|
||||
* Since self-hosting Inter (GDPR fix), there is no need to allow
|
||||
* font-src 'self' https: data:
|
||||
* style-src 'self' https: 'unsafe-inline'
|
||||
* on either the default (helmet) CSP or the /docs custom CSP.
|
||||
*
|
||||
* - font-src should be "'self'" only
|
||||
* - style-src should allow 'self' and 'unsafe-inline' (inline style="..." in HTML),
|
||||
* but NOT https:
|
||||
* - /docs must still allow 'unsafe-eval' in script-src for Swagger UI
|
||||
*/
|
||||
describe("Content-Security-Policy headers", () => {
|
||||
describe("default CSP (helmet) on landing page", () => {
|
||||
it("font-src is 'self' only (no https:, no data:)", async () => {
|
||||
const res = await request(app).get("/");
|
||||
const csp = res.headers["content-security-policy"];
|
||||
expect(csp, "content-security-policy header missing").toBeDefined();
|
||||
expect(csp).toMatch(/font-src\s+'self'(?:\s*;|\s*$)/);
|
||||
expect(csp).not.toMatch(/font-src[^;]*https:/);
|
||||
expect(csp).not.toMatch(/font-src[^;]*\bdata:/);
|
||||
});
|
||||
|
||||
it("style-src contains 'self' and 'unsafe-inline' but no https:", async () => {
|
||||
const res = await request(app).get("/");
|
||||
const csp = res.headers["content-security-policy"];
|
||||
expect(csp).toBeDefined();
|
||||
expect(csp).toMatch(/style-src[^;]*'self'/);
|
||||
expect(csp).toMatch(/style-src[^;]*'unsafe-inline'/);
|
||||
expect(csp).not.toMatch(/style-src[^;]*https:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom CSP on /docs (Swagger UI)", () => {
|
||||
it("font-src is 'self' only", async () => {
|
||||
const res = await request(app).get("/docs");
|
||||
const csp = res.headers["content-security-policy"];
|
||||
expect(csp, "content-security-policy header missing on /docs").toBeDefined();
|
||||
expect(csp).toMatch(/font-src\s+'self'(?:\s*;|\s*$)/);
|
||||
expect(csp).not.toMatch(/font-src[^;]*https:/);
|
||||
expect(csp).not.toMatch(/font-src[^;]*\bdata:/);
|
||||
});
|
||||
|
||||
it("style-src contains 'self' and 'unsafe-inline' but no https:", async () => {
|
||||
const res = await request(app).get("/docs");
|
||||
const csp = res.headers["content-security-policy"];
|
||||
expect(csp).toBeDefined();
|
||||
expect(csp).toMatch(/style-src[^;]*'self'/);
|
||||
expect(csp).toMatch(/style-src[^;]*'unsafe-inline'/);
|
||||
expect(csp).not.toMatch(/style-src[^;]*https:/);
|
||||
});
|
||||
|
||||
it("script-src still contains 'unsafe-eval' (required by Swagger UI)", async () => {
|
||||
const res = await request(app).get("/docs");
|
||||
const csp = res.headers["content-security-policy"];
|
||||
expect(csp).toBeDefined();
|
||||
expect(csp).toMatch(/script-src[^;]*'unsafe-eval'/);
|
||||
});
|
||||
});
|
||||
});
|
||||
16
src/index.ts
16
src/index.ts
|
|
@ -30,7 +30,21 @@ import { startPeriodicCleanup, stopPeriodicCleanup } from "./utils/periodic-clea
|
|||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
||||
// Content-Security-Policy: tightened now that Inter font is self-hosted.
|
||||
// - font-src: 'self' only (no external CDNs, no data: URIs)
|
||||
// - style-src: 'self' + 'unsafe-inline' (codebase uses inline style="..." attributes)
|
||||
// - script-src: 'self' (default; /docs overrides to add 'unsafe-eval' for Swagger UI)
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
"font-src": ["'self'"],
|
||||
"style-src": ["'self'", "'unsafe-inline'"],
|
||||
},
|
||||
},
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
})
|
||||
);
|
||||
|
||||
// Request ID + request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ pagesRouter.get("/openapi.json", (_req: Request, res: Response) => {
|
|||
pagesRouter.get("/docs", (_req: Request, res: Response) => {
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' https: 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' https: data:;connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'"
|
||||
"default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' 'unsafe-inline';img-src 'self' data: blob:;font-src 'self';connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'"
|
||||
);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.sendFile(path.join(publicDir, "docs.html"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue