diff --git a/apps/cms/middleware.ts b/apps/cms/middleware.ts new file mode 100644 index 0000000..72db1ee --- /dev/null +++ b/apps/cms/middleware.ts @@ -0,0 +1,18 @@ +import { buildSecurityHeaders } from "@repo/core-shared/security"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export function middleware(_request: NextRequest): NextResponse { + const mode = process.env.NODE_ENV === "production" ? "prod" : "dev"; + const secHeaders = buildSecurityHeaders({ mode }); + + const response = NextResponse.next(); + for (const [name, value] of Object.entries(secHeaders)) { + response.headers.set(name, value); + } + return response; +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/apps/cms/src/middleware.test.ts b/apps/cms/src/middleware.test.ts new file mode 100644 index 0000000..0ba9468 --- /dev/null +++ b/apps/cms/src/middleware.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const responseMock = vi.hoisted(() => { + function makeResponseMock() { + const store = new Map(); + return { + _store: store, + headers: { + set: vi.fn((k: string, v: string) => store.set(k, v)), + get: vi.fn((k: string) => store.get(k) ?? null), + }, + }; + } + return { makeResponseMock }; +}); + +vi.mock("next/server", () => ({ + NextResponse: { + next: vi.fn(), + }, +})); + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { middleware } from "../middleware"; + +const ALL_SIX_HEADERS = [ + "Strict-Transport-Security", + "X-Frame-Options", + "X-Content-Type-Options", + "Referrer-Policy", + "Permissions-Policy", + "Content-Security-Policy", +] as const; + +function makeRequest(): NextRequest { + return { headers: new Headers() } as unknown as NextRequest; +} + +describe("cms middleware", () => { + let mock: ReturnType; + + beforeEach(() => { + mock = responseMock.makeResponseMock(); + vi.mocked(NextResponse.next).mockReturnValue( + mock as unknown as ReturnType, + ); + }); + + it("sets all six security headers on the response", () => { + middleware(makeRequest()); + + for (const header of ALL_SIX_HEADERS) { + expect(mock._store.has(header)).toBe(true); + } + }); + + it("does not set a nonce header", () => { + middleware(makeRequest()); + + expect(mock._store.has("x-nonce")).toBe(false); + }); + + it("CSP is permissive in development mode", () => { + vi.stubEnv("NODE_ENV", "development"); + + middleware(makeRequest()); + + const csp = mock._store.get("Content-Security-Policy"); + expect(csp).toContain("'unsafe-inline'"); + }); + + it("CSP uses strict-dynamic in production mode", () => { + vi.stubEnv("NODE_ENV", "production"); + + middleware(makeRequest()); + + const csp = mock._store.get("Content-Security-Policy"); + expect(csp).toContain("'strict-dynamic'"); + }); +});