diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 03fd837..dacb855 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -31,6 +31,16 @@ app.onError((err, c) => { return c.json({ error: "Internal Server Error" }, 500); }); +// Deny framing of every first-party page. The shell carries sharing controls, +// so an attacker embedding it could clickjack them. Safe as a blanket header: +// the only iframe we render is the document via `srcdoc`, which is not a URL +// fetch and so is unaffected by frame-ancestors. +app.use("/*", async (c, next) => { + await next(); + c.header("Content-Security-Policy", "frame-ancestors 'none'"); + c.header("X-Frame-Options", "DENY"); +}); + app.get("/health", (c) => c.json({ status: "ok" })); app.use("/*", authMiddleware); diff --git a/apps/worker/tests/health.test.ts b/apps/worker/tests/health.test.ts index 15743c5..0fe27d3 100644 --- a/apps/worker/tests/health.test.ts +++ b/apps/worker/tests/health.test.ts @@ -14,4 +14,10 @@ describe("GET /health", () => { }); expect(response.status).toBe(200); }); + + it("denies framing on every response", async () => { + const response = await exports.default.fetch("https://example.com/health"); + expect(response.headers.get("Content-Security-Policy")).toBe("frame-ancestors 'none'"); + expect(response.headers.get("X-Frame-Options")).toBe("DENY"); + }); });