diff --git a/next.config.ts b/next.config.ts index 6fd2695..04eb1f2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,47 +1,20 @@ import type { NextConfig } from "next"; - -const securityHeaders = [ - { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, - { key: "X-Content-Type-Options", value: "nosniff" }, - { key: "X-Frame-Options", value: "SAMEORIGIN" }, - { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, - { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, - { key: "X-XSS-Protection", value: "1; mode=block" }, - { key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" }, - { - key: "Content-Security-Policy", - value: [ - "default-src 'self'", - // Allow Turnstile + Next.js inline scripts (Next injects inline scripts) - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com", - // Allow Tailwind-injected styles + Google Fonts - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", - // Allow Google Fonts woff2 + Turnstile font assets - "font-src 'self' https://fonts.gstatic.com data: https://challenges.cloudflare.com", - // Allow our images + Turnstile injected pixel/favicon - "img-src 'self' data: blob: https://challenges.cloudflare.com", - // Turnstile connects to challenges.cloudflare.com for widget + validation - "connect-src 'self' https://horizon-testnet.stellar.org https://api.stellar.org https://challenges.cloudflare.com", - // Turnstile renders in an iframe hosted on challenges.cloudflare.com - "frame-src https://challenges.cloudflare.com", - "worker-src 'self' blob:", - "base-uri 'self'", - "form-action 'self'", - "frame-ancestors 'self'", - ].join("; "), - }, -]; +import { SECURITY_HEADERS } from "./src/lib/security-edge"; const nextConfig: NextConfig = { async headers() { return [ { + // Covers every response, including static assets/paths that the + // middleware matcher excludes (see config.matcher in + // src/middleware.ts) - see SECURITY_HEADERS in + // src/lib/security-edge.ts for the single source of truth. source: "/(.*)", - headers: securityHeaders, + headers: SECURITY_HEADERS as { key: string; value: string }[], }, ]; }, poweredByHeader: false, }; -export default nextConfig; \ No newline at end of file +export default nextConfig; diff --git a/src/lib/__tests__/security-edge.test.ts b/src/lib/__tests__/security-edge.test.ts new file mode 100644 index 0000000..35af657 --- /dev/null +++ b/src/lib/__tests__/security-edge.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { applySecurityHeaders, SECURITY_HEADERS, isAllowedOrigin, ALLOWED_ORIGINS } from "@/lib/security-edge"; + +// Issue #368: security-edge.ts must set these on *every* response, not just +// some routes. next.config.ts's `headers()` also reuses SECURITY_HEADERS so +// the middleware-excluded static paths (see config.matcher in +// src/middleware.ts) get identical values instead of a second, drifted copy. +const REQUIRED_HEADERS = [ + "Content-Security-Policy", + "X-Frame-Options", + "X-Content-Type-Options", + "Referrer-Policy", + "Strict-Transport-Security", +]; + +describe("SECURITY_HEADERS", () => { + it("includes all 5 headers required by issue #368", () => { + const keys = SECURITY_HEADERS.map((h) => h.key); + for (const required of REQUIRED_HEADERS) { + expect(keys).toContain(required); + } + }); + + it("has no duplicate keys", () => { + const keys = SECURITY_HEADERS.map((h) => h.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("sets a restrictive default-src in the CSP", () => { + const csp = SECURITY_HEADERS.find((h) => h.key === "Content-Security-Policy"); + expect(csp?.value).toContain("default-src 'self'"); + }); + + it("uses the strict, non-framable X-Frame-Options value", () => { + const xfo = SECURITY_HEADERS.find((h) => h.key === "X-Frame-Options"); + expect(xfo?.value).toBe("DENY"); + }); +}); + +describe("applySecurityHeaders", () => { + it("sets every required header on the response", () => { + const response = applySecurityHeaders(NextResponse.next()); + for (const required of REQUIRED_HEADERS) { + expect(response.headers.get(required)).toBeTruthy(); + } + }); + + it("applies every entry from SECURITY_HEADERS, not a hand-picked subset", () => { + const response = applySecurityHeaders(NextResponse.next()); + for (const { key, value } of SECURITY_HEADERS) { + expect(response.headers.get(key)).toBe(value); + } + }); + + it("returns the same response instance it was given", () => { + const input = NextResponse.next(); + expect(applySecurityHeaders(input)).toBe(input); + }); +}); + +describe("isAllowedOrigin", () => { + it("allows same-origin/non-CORS requests (null origin)", () => { + expect(isAllowedOrigin(null)).toBe(true); + }); + + it("allows the production and Vercel preview domains", () => { + expect(isAllowedOrigin("https://remitx.app")).toBe(true); + expect(isAllowedOrigin("https://remitx.vercel.app")).toBe(true); + }); + + it("rejects an origin that isn't in the allowlist", () => { + expect(isAllowedOrigin("https://evil.example.com")).toBe(false); + }); + + it("keeps ALLOWED_ORIGINS and isAllowedOrigin in sync", () => { + for (const origin of ALLOWED_ORIGINS) { + expect(isAllowedOrigin(origin)).toBe(true); + } + }); +}); diff --git a/src/lib/security-edge.ts b/src/lib/security-edge.ts index 45a90a7..8ed8ee8 100644 --- a/src/lib/security-edge.ts +++ b/src/lib/security-edge.ts @@ -6,14 +6,52 @@ import { NextResponse } from "next/server"; * imported by middleware (which runs in the Edge Runtime). */ +// Single source of truth for the response security headers. `next.config.ts` +// imports this same list for its `headers()` config so that static assets +// excluded from the middleware matcher (see `config.matcher` in +// middleware.ts - _next/static, images, .css/.js, etc.) still get the exact +// same header values instead of a second, hand-copied set that can drift out +// of sync (previously next.config.ts had X-Frame-Options: SAMEORIGIN and +// Cross-Origin-Opener-Policy: same-origin-allow-popups while this file used +// stricter DENY / same-origin - two different policies for the same site +// depending on which path served the response). +export const SECURITY_HEADERS: ReadonlyArray<{ key: string; value: string }> = [ + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + // Allow Turnstile + Next.js inline scripts (Next injects inline scripts) + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com", + // Allow Tailwind-injected styles + Google Fonts + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + // Allow Google Fonts woff2 + Turnstile font assets + "font-src 'self' https://fonts.gstatic.com data: https://challenges.cloudflare.com", + // Allow our images + Turnstile injected pixel/favicon + "img-src 'self' data: blob: https://challenges.cloudflare.com", + // Turnstile connects to challenges.cloudflare.com for widget + validation + "connect-src 'self' https://horizon-testnet.stellar.org https://api.stellar.org https://challenges.cloudflare.com", + // Turnstile renders in an iframe hosted on challenges.cloudflare.com + "frame-src https://challenges.cloudflare.com", + "worker-src 'self' blob:", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", + ].join("; "), + }, + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "X-XSS-Protection", value: "1; mode=block" }, + { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, +]; + export function applySecurityHeaders(response: NextResponse): NextResponse { - response.headers.set("X-Content-Type-Options", "nosniff"); - response.headers.set("X-Frame-Options", "DENY"); - response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); - response.headers.set("X-XSS-Protection", "1; mode=block"); - response.headers.set("Cross-Origin-Opener-Policy", "same-origin"); - response.headers.set("Cross-Origin-Resource-Policy", "same-origin"); + for (const { key, value } of SECURITY_HEADERS) { + response.headers.set(key, value); + } return response; } diff --git a/src/lib/security.ts b/src/lib/security.ts index 1f13767..4009116 100644 --- a/src/lib/security.ts +++ b/src/lib/security.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import crypto from "crypto"; /** @@ -198,29 +198,6 @@ export function validateUpload( return { valid: true }; } -// ── CORS lockdown ─────────────────────────────────────────────────────── -export const ALLOWED_ORIGINS = new Set([ - "https://remitx.app", - "http://localhost:3000", - "http://localhost:3001", -]); - -export function isAllowedOrigin(origin: string | null): boolean { - if (!origin) return false; - return ALLOWED_ORIGINS.has(origin); -} - -export function applySecurityHeaders(response: NextResponse): NextResponse { - response.headers.set("X-Content-Type-Options", "nosniff"); - response.headers.set("X-Frame-Options", "DENY"); - response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); - response.headers.set("X-XSS-Protection", "1; mode=block"); - response.headers.set("Cross-Origin-Opener-Policy", "same-origin"); - response.headers.set("Cross-Origin-Resource-Policy", "same-origin"); - return response; -} - // ── Password reset token ──────────────────────────────────────────────── export function generateResetToken(userId: string): { token: string; expiresAt: number } { const expiresAt = Date.now() + 60 * 60 * 1000; // 1 hour