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

This commit is contained in:
OpenClaw Subagent 2026-04-10 14:11:30 +02:00
parent a374a93937
commit 6d7cf14a4f
3 changed files with 81 additions and 2 deletions

View 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'/);
});
});
});

View file

@ -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) => {

View file

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