diff --git a/src/middleware/rateLimit.test.ts b/src/middleware/rateLimit.test.ts index 9c226212..568ebf10 100644 --- a/src/middleware/rateLimit.test.ts +++ b/src/middleware/rateLimit.test.ts @@ -6,6 +6,7 @@ import { TokenBucketRateLimiter, createTokenBucketRateLimitMiddleware, InMemoryRateLimiter, // <-- Added missing import + createProxyRateLimitMiddleware, } from "./rateLimit.js"; import { requireAuth, type AuthenticatedLocals } from "./requireAuth.js"; import { TEST_JWT_SECRET, signTestToken } from "../../tests/helpers/jwt.js"; @@ -323,3 +324,94 @@ describe("token bucket rate limit middleware", () => { expect(message).toBe("Too Many Requests"); }); }); + +describe("proxy rate limit middleware", () => { + const originalSecret = process.env.JWT_SECRET; + + function buildProxyApp(capacity = 3, refillRate = 1) { + const app = express(); + const rateLimit = createProxyRateLimitMiddleware({ + capacity, + refillRate, + }); + + app.get( + "/proxy", + // Fake auth middleware that simulates gateway proxy auth + (req, res, next) => { + const apiKey = req.headers["x-api-key"]; + if (apiKey === "key-1") { + (req as any).apiKeyRecord = { userId: "dev-1" }; + } else if (apiKey === "key-2") { + (req as any).apiKeyRecord = { userId: "dev-2" }; + } + next(); + }, + rateLimit, + (_req, res) => { + res.json({ ok: true }); + }, + ); + + app.use(errorHandler); + return app; + } + + beforeEach(() => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + jest.spyOn(logger, "warn").mockImplementation(() => {}); + jest.spyOn(logger, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + if (originalSecret !== undefined) { + process.env.JWT_SECRET = originalSecret; + } else { + delete process.env.JWT_SECRET; + } + }); + + test("uses apiKeyRecord.userId for rate limiting when available", async () => { + const app = buildProxyApp(2, 1); + + await request(app).get("/proxy").set("x-api-key", "key-1").expect(200); + await request(app).get("/proxy").set("x-api-key", "key-1").expect(200); + const response = await request(app).get("/proxy").set("x-api-key", "key-1"); + + expect(response.status).toBe(429); + expect(response.headers["retry-after"]).toBe("1"); + + // key-2 should still be allowed + await request(app).get("/proxy").set("x-api-key", "key-2").expect(200); + }); + + test("falls back to bearer token if apiKeyRecord is missing", async () => { + const app = buildProxyApp(1, 1); + const token = signTestToken({ + userId: "user-fallback", + walletAddress: "GDTEST123STELLAR", + }); + + await request(app) + .get("/proxy") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + const response = await request(app) + .get("/proxy") + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(429); + }); + + test("falls back to IP if no auth is present", async () => { + const app = buildProxyApp(1, 1); + + await request(app).get("/proxy").expect(200); + const response = await request(app).get("/proxy"); + + expect(response.status).toBe(429); + expect(response.body.error.code).toBe("TOO_MANY_REQUESTS"); + }); +}); diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index fcabe58d..4460f726 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -212,6 +212,54 @@ export function createQuotaRateLimitMiddleware( }; } +/** + * Creates a per-user token-bucket rate limit middleware for the proxy route. + */ +export function createProxyRateLimitMiddleware( + options?: TokenBucketOptions, + limiter?: TokenBucketRateLimiter, +): RequestHandler { + const opts: TokenBucketOptions = options ?? { capacity: 100, refillRate: 10 }; + const bucket = limiter ?? new TokenBucketRateLimiter(opts.capacity, opts.refillRate); + + return (req: Request, res: Response, next: NextFunction): void => { + let key: string; + const apiKeyRecord = (req as any).apiKeyRecord; + + if (apiKeyRecord && apiKeyRecord.userId) { + key = `user:${apiKeyRecord.userId}`; + } else { + const { userId } = resolveRequestUserId(req); + if (userId) { + key = `user:${userId}`; + } else { + const clientIp = getClientIp(req); + key = `ip:${clientIp}`; + } + } + + const result = bucket.check(key); + + if (!result.allowed) { + const retryAfterMs = result.retryAfterMs ?? 1000; + const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)); + const requestId = getRequestId(req); + + logger.warn("[proxyRateLimit] request limit exceeded", { + requestId, + key, + retryAfterMs, + }); + + res.set("Retry-After", String(retryAfterSeconds)); + next(new TooManyRequestsError("Too Many Requests")); + return; + } + + next(); + }; +} + // ─── Fixed-Window Rate Limiter ─────────────────────────────────────────────── /** diff --git a/src/routes/proxyRoutes.ts b/src/routes/proxyRoutes.ts index 5811e62e..a5c45a25 100644 --- a/src/routes/proxyRoutes.ts +++ b/src/routes/proxyRoutes.ts @@ -11,6 +11,7 @@ import { } from '../metrics.js'; import { createMapBackedGatewayApiKeyAuthMiddleware } from '../middleware/gatewayApiKeyAuth.js'; import { createConfiguredPerKeyConcurrencyMiddleware } from '../middleware/perKeyConcurrency.js'; +import { createProxyRateLimitMiddleware } from '../middleware/rateLimit.js'; import { idempotencyMiddleware } from '../middleware/idempotency.js'; import { buildHopByHopSet } from '../lib/hopByHop.js'; import { @@ -122,6 +123,9 @@ export function createProxyRouter(deps: ProxyDeps): Router { // so that req.apiKeyRecord is populated. const perKeyConcurrency = deps.perKeyConcurrency ?? createConfiguredPerKeyConcurrencyMiddleware(); + // Per-user token-bucket rate limit for the proxy route. + const proxyRateLimit = deps.proxyRateLimit ?? createProxyRateLimitMiddleware(); + // Idempotency middleware for POST/PATCH to prevent duplicate downstream calls. // Caches request→response keyed by Idempotency-Key header, ensuring safe retries. // See docs/api-proxy-idempotency.md for the contract. @@ -170,23 +174,23 @@ export function createProxyRouter(deps: ProxyDeps): Router { // Use a param of 0 to capture the wildcard path (everything after the slug) // POST and PATCH routes get idempotency protection; GET/DELETE are naturally safe. - router.post('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, idempotencyForProxy, handleProxy); - router.patch('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, idempotencyForProxy, handleProxy); - router.post('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, idempotencyForProxy, handleProxy); - router.patch('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, idempotencyForProxy, handleProxy); + router.post('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, idempotencyForProxy, handleProxy); + router.patch('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, idempotencyForProxy, handleProxy); + router.post('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, idempotencyForProxy, handleProxy); + router.patch('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, idempotencyForProxy, handleProxy); // GET, DELETE, and other methods pass through without idempotency caching - router.get('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - router.delete('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - router.put('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - router.options('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - router.head('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - - router.get('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - router.delete('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - router.put('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - router.options('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); - router.head('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy); + router.get('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + router.delete('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + router.put('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + router.options('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + router.head('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + + router.get('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + router.delete('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + router.put('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + router.options('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); + router.head('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy); async function handleProxy(req: Request, res: Response, next: NextFunction): Promise { try { diff --git a/src/types/gateway.ts b/src/types/gateway.ts index 168a1bcc..ce477481 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -166,6 +166,7 @@ export interface ProxyDeps { authMiddleware?: RequestHandler; /** Per-API-key concurrency tracker. Defaults to the shared-semaphore middleware. */ perKeyConcurrency?: RequestHandler; + proxyRateLimit?: RequestHandler; proxyConfig?: Partial; circuitBreakerStore?: CircuitBreakerStore; /**