From 4ac45b425aeb7715164f2de0a1f7777be8597643 Mon Sep 17 00:00:00 2001 From: David Ejere Date: Wed, 27 May 2026 08:13:47 +0100 Subject: [PATCH] feat(payments): rate-limit /path-payment-quote/:id (#599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-payment-id rate limiter to GET /api/path-payment-quote/:id. Each (paymentId, actor) pair is throttled independently (actor = API key hash, merchant id, or client IP — same precedence as the create-payment limiter), so a leaked API key cannot fan out across unrelated payment ids and a single payment id cannot be flooded from one IP. Defaults: 20 requests / 60s. Configurable via PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX and PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS. Returns 429 with X-RateLimit-* / Retry-After headers when blocked. Co-Authored-By: Claude --- .../src/lib/path-payment-quote-rate-limit.js | 113 +++++++++++ .../lib/path-payment-quote-rate-limit.test.js | 175 ++++++++++++++++++ backend/src/routes/payments.js | 3 + 3 files changed, 291 insertions(+) create mode 100644 backend/src/lib/path-payment-quote-rate-limit.js create mode 100644 backend/src/lib/path-payment-quote-rate-limit.test.js diff --git a/backend/src/lib/path-payment-quote-rate-limit.js b/backend/src/lib/path-payment-quote-rate-limit.js new file mode 100644 index 0000000..bc40a96 --- /dev/null +++ b/backend/src/lib/path-payment-quote-rate-limit.js @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; + +const DEFAULT_PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX = 20; +const DEFAULT_PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS = 60 * 1000; +const PATH_PAYMENT_QUOTE_RATE_LIMIT_ERROR = + "Too many path payment quote requests, please try again later."; + +function parsePositiveInteger(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + + const parsedValue = Number.parseInt(String(value), 10); + + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + return fallback; + } + + return parsedValue; +} + +export function getPathPaymentQuoteRateLimitConfig(env = process.env) { + return { + max: parsePositiveInteger( + env.PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX, + DEFAULT_PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX + ), + windowMs: parsePositiveInteger( + env.PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS, + DEFAULT_PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS + ), + }; +} + +export function getPathPaymentQuoteRateLimitKey(req) { + const paymentId = + typeof req?.params?.id === "string" && req.params.id.length > 0 + ? req.params.id + : "unknown-payment"; + + const apiKey = req.get?.("x-api-key")?.trim(); + if (apiKey) { + const hashedKey = createHash("sha256").update(apiKey).digest("hex"); + return `${paymentId}:api:${hashedKey}`; + } + + if (req.merchant?.id) { + return `${paymentId}:merchant:${req.merchant.id}`; + } + + return `${paymentId}:ip:${ipKeyGenerator(req.ip ?? req.socket?.remoteAddress ?? "unknown-ip")}`; +} + +export function getRetryAfterSeconds(resetTime, now = new Date(), windowMs = DEFAULT_PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS) { + if (!(resetTime instanceof Date) || Number.isNaN(resetTime.getTime())) { + return Math.max(1, Math.ceil(windowMs / 1000)); + } + + const remainingMs = resetTime.getTime() - now.getTime(); + return Math.max(1, Math.ceil(remainingMs / 1000)); +} + +export function createPathPaymentQuoteRateLimit({ + env = process.env, + limiterFactory = rateLimit, +} = {}) { + const config = getPathPaymentQuoteRateLimitConfig(env); + + return limiterFactory({ + windowMs: config.windowMs, + max: config.max, + standardHeaders: true, + legacyHeaders: false, + validate: { ip: false }, + keyGenerator: getPathPaymentQuoteRateLimitKey, + requestWasSuccessful(req, res) { + if (typeof req.rateLimit?.limit === "number") { + res.set("X-RateLimit-Limit", String(req.rateLimit.limit)); + } + if (typeof req.rateLimit?.remaining === "number") { + res.set("X-RateLimit-Remaining", String(req.rateLimit.remaining)); + } + if ( + req.rateLimit?.resetTime instanceof Date && + !Number.isNaN(req.rateLimit.resetTime.getTime()) + ) { + res.set( + "X-RateLimit-Reset", + String(Math.floor(req.rateLimit.resetTime.getTime() / 1000)) + ); + } + + return res.statusCode < 400; + }, + handler(req, res) { + const retryAfterSeconds = getRetryAfterSeconds( + req.rateLimit?.resetTime, + new Date(), + config.windowMs + ); + + res.set("Retry-After", String(retryAfterSeconds)); + res.status(429).json({ error: PATH_PAYMENT_QUOTE_RATE_LIMIT_ERROR }); + }, + }); +} + +export { + PATH_PAYMENT_QUOTE_RATE_LIMIT_ERROR, + DEFAULT_PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX, + DEFAULT_PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS, +}; diff --git a/backend/src/lib/path-payment-quote-rate-limit.test.js b/backend/src/lib/path-payment-quote-rate-limit.test.js new file mode 100644 index 0000000..3868d09 --- /dev/null +++ b/backend/src/lib/path-payment-quote-rate-limit.test.js @@ -0,0 +1,175 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { + PATH_PAYMENT_QUOTE_RATE_LIMIT_ERROR, + createPathPaymentQuoteRateLimit, + getPathPaymentQuoteRateLimitConfig, + getPathPaymentQuoteRateLimitKey, + getRetryAfterSeconds, +} from "./path-payment-quote-rate-limit.js"; + +function createRequest({ + apiKey, + merchantId, + ip = "127.0.0.1", + paymentId = "payment-1", + resetTime, +} = {}) { + return { + ip, + params: paymentId ? { id: paymentId } : {}, + merchant: merchantId ? { id: merchantId } : undefined, + rateLimit: resetTime ? { resetTime } : undefined, + get(name) { + if (name.toLowerCase() === "x-api-key") { + return apiKey; + } + + return undefined; + }, + }; +} + +function createResponse() { + return { + json: vi.fn(), + set: vi.fn(), + status: vi.fn(), + }; +} + +describe("path-payment-quote rate limit config", () => { + it("uses the default rate-limit settings", () => { + expect(getPathPaymentQuoteRateLimitConfig({})).toEqual({ + max: 20, + windowMs: 60 * 1000, + }); + }); + + it("uses environment overrides when present", () => { + expect( + getPathPaymentQuoteRateLimitConfig({ + PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX: "40", + PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS: "120000", + }) + ).toEqual({ + max: 40, + windowMs: 120000, + }); + }); + + it("falls back to defaults for invalid environment overrides", () => { + expect( + getPathPaymentQuoteRateLimitConfig({ + PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX: "0", + PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS: "nope", + }) + ).toEqual({ + max: 20, + windowMs: 60 * 1000, + }); + }); +}); + +describe("getPathPaymentQuoteRateLimitKey", () => { + it("scopes the key by payment id and hashed API key when the header is present", () => { + const apiKey = " live_test_key "; + const hashedKey = createHash("sha256").update("live_test_key").digest("hex"); + + expect( + getPathPaymentQuoteRateLimitKey( + createRequest({ apiKey, paymentId: "pay-1" }) + ) + ).toBe(`pay-1:api:${hashedKey}`); + }); + + it("scopes by merchant id when no API key header is present", () => { + expect( + getPathPaymentQuoteRateLimitKey( + createRequest({ merchantId: "merchant-9", paymentId: "pay-2" }) + ) + ).toBe("pay-2:merchant:merchant-9"); + }); + + it("scopes by ip address when neither API key nor merchant id is available", () => { + expect( + getPathPaymentQuoteRateLimitKey( + createRequest({ ip: "10.0.0.4", paymentId: "pay-3" }) + ) + ).toBe("pay-3:ip:10.0.0.4"); + }); + + it("uses an unknown-payment marker when params.id is missing", () => { + const req = createRequest({ ip: "10.0.0.4" }); + req.params = {}; + + expect(getPathPaymentQuoteRateLimitKey(req)).toBe( + "unknown-payment:ip:10.0.0.4" + ); + }); +}); + +describe("getRetryAfterSeconds", () => { + it("rounds up the remaining wait time in seconds", () => { + const now = new Date("2026-03-26T12:00:00.000Z"); + const resetTime = new Date("2026-03-26T12:00:02.100Z"); + + expect(getRetryAfterSeconds(resetTime, now, 60 * 1000)).toBe(3); + }); + + it("falls back to the window duration when reset time is unavailable", () => { + const now = new Date("2026-03-26T12:00:00.000Z"); + + expect(getRetryAfterSeconds(undefined, now, 90 * 1000)).toBe(90); + }); +}); + +describe("createPathPaymentQuoteRateLimit", () => { + it("passes the expected config to the limiter factory", () => { + const limiter = vi.fn(); + const limiterFactory = vi.fn(() => limiter); + + const result = createPathPaymentQuoteRateLimit({ + env: { + PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX: "25", + PATH_PAYMENT_QUOTE_RATE_LIMIT_WINDOW_MS: "90000", + }, + limiterFactory, + }); + + expect(result).toBe(limiter); + expect(limiterFactory).toHaveBeenCalledTimes(1); + + const [config] = limiterFactory.mock.calls[0]; + expect(config.max).toBe(25); + expect(config.windowMs).toBe(90000); + expect(config.standardHeaders).toBe(true); + expect(config.legacyHeaders).toBe(false); + expect(typeof config.keyGenerator).toBe("function"); + expect(typeof config.handler).toBe("function"); + }); + + it("returns a 429 response with Retry-After when the limiter blocks a request", () => { + const limiterFactory = vi.fn((config) => config); + const limiterConfig = createPathPaymentQuoteRateLimit({ limiterFactory }); + const req = createRequest({ + apiKey: "limited-key", + resetTime: new Date("2026-03-26T12:00:03.000Z"), + }); + const res = createResponse(); + res.status.mockReturnValue(res); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-26T12:00:00.000Z")); + + limiterConfig.handler(req, res); + + expect(res.set).toHaveBeenCalledWith("Retry-After", "3"); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ + error: PATH_PAYMENT_QUOTE_RATE_LIMIT_ERROR, + }); + + vi.useRealTimers(); + }); +}); diff --git a/backend/src/routes/payments.js b/backend/src/routes/payments.js index f8451aa..b97fd83 100644 --- a/backend/src/routes/payments.js +++ b/backend/src/routes/payments.js @@ -15,6 +15,7 @@ import { import { validateRequest } from "../lib/validation.js"; import { createCreatePaymentRateLimit } from "../lib/create-payment-rate-limit.js"; import { createVerifyPaymentRateLimit } from "../lib/rate-limit.js"; +import { createPathPaymentQuoteRateLimit } from "../lib/path-payment-quote-rate-limit.js"; import { recaptchaMiddleware } from "../lib/recaptcha.js"; import { sendWebhook, isEventSubscribed } from "../lib/webhooks.js"; import { sendReceiptEmail } from "../lib/email.js"; @@ -48,6 +49,7 @@ import { const createPaymentRateLimit = createCreatePaymentRateLimit(); +const pathPaymentQuoteRateLimit = createPathPaymentQuoteRateLimit(); const defaultVerifyPaymentRateLimit = createVerifyPaymentRateLimit(); let supabaseClientPromise; @@ -1136,6 +1138,7 @@ function createPaymentsRouter({ */ router.get( "/path-payment-quote/:id", + pathPaymentQuoteRateLimit, validateUuidParam(), validateRequest({ query: pathPaymentQuoteQuerySchema }), async (req, res, next) => {