From 550c5b21325312a57360d33e0dc12b0cf94abe4b Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Fri, 9 Oct 2026 22:22:44 +0000 Subject: [PATCH] fix(security): cabecalhos de seguranca em todas as rotas (auditoria COMP-003) - frame-ancestors 'none' e X-Frame-Options DENY (sem clickjacking), base-uri, form-action e object-src restritos - nosniff, Referrer-Policy, Permissions-Policy e HSTS (sem preload) - CSP completa com nonce fica para depois; teste fixa os cabecalhos Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VDxbrneS3TEAUmdgVmkKpQ --- next.config.ts | 2 ++ src/lib/security-headers.test.ts | 34 ++++++++++++++++++++++++++++++++ src/lib/security-headers.ts | 17 ++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 src/lib/security-headers.test.ts create mode 100644 src/lib/security-headers.ts diff --git a/next.config.ts b/next.config.ts index ec383bb..75e1d44 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import type { NextConfig } from "next"; +import { securityHeaders } from "./src/lib/security-headers"; const { version } = JSON.parse(readFileSync("./package.json", "utf8")) as { version: string }; @@ -17,6 +18,7 @@ const nextConfig: NextConfig = { partialPrefetching: true, async headers() { return [ + { source: "/:path*", headers: securityHeaders }, { // The browser must always revalidate the worker script so updates reach installed PWAs. source: "/sw.js", diff --git a/src/lib/security-headers.test.ts b/src/lib/security-headers.test.ts new file mode 100644 index 0000000..1baa7ad --- /dev/null +++ b/src/lib/security-headers.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import nextConfig from "../../next.config"; +import { securityHeaders } from "./security-headers"; + +const header = (key: string) => securityHeaders.find((h) => h.key === key)?.value; + +describe("security headers (COMP-003)", () => { + it("applies to every path", async () => { + const rules = (await nextConfig.headers?.()) ?? []; + const all = rules.find((r) => r.source === "/:path*"); + expect(all?.headers).toEqual(securityHeaders); + }); + + it("forbids framing in both the modern and the legacy way", () => { + expect(header("Content-Security-Policy")).toContain("frame-ancestors 'none'"); + expect(header("X-Frame-Options")).toBe("DENY"); + }); + + it("sets the rest of the baseline", () => { + expect(header("X-Content-Type-Options")).toBe("nosniff"); + expect(header("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + expect(header("Permissions-Policy")).toContain("camera=()"); + expect(header("Strict-Transport-Security")).toMatch(/^max-age=\d+$/); + }); + + it("never preloads HSTS (that is a decision about the whole domain)", () => { + expect(header("Strict-Transport-Security")).not.toMatch(/preload|includeSubDomains/); + }); + + it("keeps the service worker revalidation rule", async () => { + const rules = (await nextConfig.headers?.()) ?? []; + expect(rules.find((r) => r.source === "/sw.js")?.headers.some((h) => h.key === "Cache-Control")).toBe(true); + }); +}); diff --git a/src/lib/security-headers.ts b/src/lib/security-headers.ts new file mode 100644 index 0000000..8f0f3f2 --- /dev/null +++ b/src/lib/security-headers.ts @@ -0,0 +1,17 @@ +/** + * Security headers sent on every response (audit COMP-003). Kept in a plain module so a test can pin them. + * + * Deliberately NOT a full Content-Security-Policy yet: `script-src` needs per-request nonces with Next 16 and the app has + * inline scripts, so a strict policy must be tried page by page. This subset has no effect on how pages render. + * - `frame-ancestors 'none'` / `X-Frame-Options: DENY`: no framing, so no clickjacking of admin actions. + * - `form-action 'self'`, `base-uri 'self'`, `object-src 'none'`: close the cheapest injection follow-ups. + * - HSTS without `preload`: preload would be a decision about the whole assertlab.com domain, not about this app. + */ +export const securityHeaders = [ + { key: "Content-Security-Policy", value: "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Strict-Transport-Security", value: "max-age=63072000" }, +];