diff --git a/.env.example b/.env.example index 88496a1..ffe8ca2 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,8 @@ BCRYPT_COST_FACTOR=12 # ----------------------------------------------------------------------------- UPSTREAM_URL=http://localhost:4000 PROXY_TIMEOUT_MS=30000 +REST_RATE_LIMIT_WINDOW_MS=60000 +REST_RATE_LIMIT_MAX_REQUESTS=100 # ----------------------------------------------------------------------------- # CORS — comma-separated list of allowed origins diff --git a/README.md b/README.md index 43ba04d..13451ee 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ API gateway, usage metering, and billing services for the Callora API marketplac - `POST /api/apis` for authenticated developers to register an API with priced endpoints - Usage route: `GET /api/usage` - JSON body parsing plus gateway API key authentication for upstream proxy routes +- Per-user global REST rate limiting for authenticated `/api/billing`, `/api/usage`, `/api/developers`, `/api/vault`, and `/api/keys` traffic, with IP fallback for unauthenticated requests - In-memory `VaultRepository` with: - `create(userId, contractId, network)` - `findByUserId(userId, network)` @@ -164,6 +165,8 @@ The app validates all environment variables at startup using [Zod](https://zod.d | `METRICS_API_KEY` | **Yes** | — | Key for `/api/metrics` in production | | `UPSTREAM_URL` | No | `http://localhost:4000` | Gateway upstream URL | | `PROXY_TIMEOUT_MS` | No | `30000` | Proxy request timeout (ms) | +| `REST_RATE_LIMIT_WINDOW_MS` | No | `60000` | Window length for REST API rate limiting (ms) | +| `REST_RATE_LIMIT_MAX_REQUESTS` | No | `100` | Max REST API requests allowed per user/IP per window | | `CORS_ALLOWED_ORIGINS` | No | `http://localhost:5173` | Comma-separated allowed origins | | `SOROBAN_RPC_ENABLED` | No | `false` | Enable Soroban RPC health check | | `SOROBAN_RPC_URL` | If `SOROBAN_RPC_ENABLED=true` | — | Soroban RPC endpoint URL | diff --git a/src/app.ts b/src/app.ts index 68db61f..bef8de4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -36,6 +36,7 @@ import { VaultController } from './controllers/vaultController.js'; import { TransactionBuilderService } from './services/transactionBuilder.js'; import { requestIdMiddleware } from './middleware/requestId.js'; import { requestLogger } from './middleware/logging.js'; +import { createConfiguredRestRateLimitMiddleware } from './middleware/restRateLimit.js'; import { metricsMiddleware, metricsEndpoint } from './metrics.js'; import { BadRequestError, @@ -76,6 +77,7 @@ const parseDate = (value: unknown): Date | null => { export const createApp = (dependencies?: Partial) => { const app = express(); + const restRateLimit = createConfiguredRestRateLimitMiddleware(); // Set database pool in locals for billing routes app.locals.dbPool = pool; @@ -253,7 +255,7 @@ export const createApp = (dependencies?: Partial) => { ); // Mount all routes including billing - app.use('/api', routes); + app.use('/api', createApiRouter({ restRateLimit })); app.get('/api/usage', requireAuth, async (req, res: express.Response, next) => { const user = res.locals.authenticatedUser; diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 012e753..bd85745 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -120,3 +120,36 @@ describe("env schema — BCRYPT_COST_FACTOR", () => { ); }); }); + +describe('env schema — REST rate limit config', () => { + it('defaults REST rate limiting values when omitted', () => { + const result = envSchema.safeParse({ ...baseEnv }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.REST_RATE_LIMIT_WINDOW_MS).toBe(60_000); + expect(result.data.REST_RATE_LIMIT_MAX_REQUESTS).toBe(100); + } + }); + + it('accepts positive integer REST rate limit values', () => { + const result = envSchema.safeParse({ + ...baseEnv, + REST_RATE_LIMIT_WINDOW_MS: '15000', + REST_RATE_LIMIT_MAX_REQUESTS: '12', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.REST_RATE_LIMIT_WINDOW_MS).toBe(15_000); + expect(result.data.REST_RATE_LIMIT_MAX_REQUESTS).toBe(12); + } + }); + + it('rejects non-positive REST rate limit values', () => { + const result = envSchema.safeParse({ + ...baseEnv, + REST_RATE_LIMIT_WINDOW_MS: '0', + REST_RATE_LIMIT_MAX_REQUESTS: '-1', + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/config/env.ts b/src/config/env.ts index fe96e8b..385ac08 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -38,6 +38,8 @@ export const envSchema = z // Proxy / Gateway UPSTREAM_URL: z.string().url().default("http://localhost:4000"), PROXY_TIMEOUT_MS: z.coerce.number().default(30_000), + REST_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + REST_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(100), // CORS CORS_ALLOWED_ORIGINS: z.string().default("http://localhost:5173"), diff --git a/src/config/index.test.ts b/src/config/index.test.ts index 1414261..5b5ef56 100644 --- a/src/config/index.test.ts +++ b/src/config/index.test.ts @@ -16,13 +16,48 @@ describe('config validation', () => { process.env.ADMIN_API_KEY = 'test-admin-key'; process.env.METRICS_API_KEY = 'test-metrics-key'; - let cfg: { config: { port: unknown; databaseUrl: string } } | undefined; + let cfg: + | { + config: { + port: unknown; + databaseUrl: string; + restRateLimit: { windowMs: number; maxRequests: number }; + }; + } + | undefined; await jest.isolateModulesAsync(async () => { cfg = await import('./index.js'); }); expect(cfg!.config.port).toBeDefined(); expect(cfg!.config.databaseUrl).toContain('postgresql://'); + expect(cfg!.config.restRateLimit.windowMs).toBe(60_000); + expect(cfg!.config.restRateLimit.maxRequests).toBe(100); + }); + + it('should expose configured REST rate limit values', async () => { + process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = 'test-secret'; + process.env.ADMIN_API_KEY = 'test-admin-key'; + process.env.METRICS_API_KEY = 'test-metrics-key'; + process.env.REST_RATE_LIMIT_WINDOW_MS = '30000'; + process.env.REST_RATE_LIMIT_MAX_REQUESTS = '25'; + + let cfg: + | { + config: { + restRateLimit: { windowMs: number; maxRequests: number }; + }; + } + | undefined; + await jest.isolateModulesAsync(async () => { + cfg = await import('./index.js'); + }); + + expect(cfg!.config.restRateLimit).toEqual({ + windowMs: 30_000, + maxRequests: 25, + }); }); it('should call process.exit(1) when required env vars are missing', async () => { diff --git a/src/config/index.ts b/src/config/index.ts index fd57e97..5bbe17f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -118,6 +118,11 @@ export const config = { timeoutMs: env.PROXY_TIMEOUT_MS, }, + restRateLimit: { + windowMs: env.REST_RATE_LIMIT_WINDOW_MS, + maxRequests: env.REST_RATE_LIMIT_MAX_REQUESTS, + }, + sorobanRpc: env.SOROBAN_RPC_ENABLED && env.SOROBAN_RPC_URL ? { diff --git a/src/middleware/requireAuth.ts b/src/middleware/requireAuth.ts index 0ed8569..9c13d16 100644 --- a/src/middleware/requireAuth.ts +++ b/src/middleware/requireAuth.ts @@ -13,82 +13,93 @@ export type AuthenticatedLocals = { /** Restrict accepted signing algorithms to prevent algorithm-confusion attacks. */ const ALLOWED_ALGORITHMS: jwt.Algorithm[] = ["HS256"]; -export const requireAuth = ( - req: Request, - res: Response, - next: NextFunction, -): void => { - let userId: string | undefined; +export interface ResolvedRequestUserId { + userId?: string; + error?: UnauthorizedError; +} +export function resolveRequestUserId(req: Request): ResolvedRequestUserId { const authHeader = req.header("authorization"); if (authHeader !== undefined) { - if (authHeader.startsWith("Bearer ")) { - const token = authHeader.slice("Bearer ".length).trim(); + if (!authHeader.startsWith("Bearer ")) { + return { + error: new UnauthorizedError( + "Invalid authorization header", + "INVALID_AUTH_HEADER", + ), + }; + } - if (!token) { - next(new UnauthorizedError("Missing token", "MISSING_TOKEN")); - return; - } + const token = authHeader.slice("Bearer ".length).trim(); + if (!token) { + return { + error: new UnauthorizedError("Missing token", "MISSING_TOKEN"), + }; + } + + const secret = process.env.JWT_SECRET; + if (!secret) { + logger.error("[requireAuth] JWT_SECRET is not configured"); + return { error: new UnauthorizedError() }; + } + + try { + const decoded = jwt.verify(token, secret, { + algorithms: ALLOWED_ALGORITHMS, + }); - const secret = process.env.JWT_SECRET; - if (!secret) { - logger.error("[requireAuth] JWT_SECRET is not configured"); - next(new UnauthorizedError()); - return; + if (typeof decoded === "string" || !decoded) { + logger.warn("[requireAuth] Token payload is not a valid object"); + return { + error: new UnauthorizedError("Invalid token", "INVALID_TOKEN"), + }; } - try { - const decoded = jwt.verify(token, secret, { - algorithms: ALLOWED_ALGORITHMS, - }); - - // jwt.verify can return a plain string for unsigned payloads - if (typeof decoded === "string" || !decoded) { - logger.warn("[requireAuth] Token payload is not a valid object"); - next(new UnauthorizedError("Invalid token", "INVALID_TOKEN")); - return; - } - - const payload = decoded as Record; - const uid = payload.userId || payload.sub; - - if (typeof uid !== "string" || uid.trim() === "") { - logger.warn("[requireAuth] Token missing required userId or sub claim"); - next( - new UnauthorizedError( - "Token missing required claims", - "MISSING_CLAIMS", - ), - ); - return; - } - - userId = uid; - } catch (err) { - // Log the failure reason but never the token contents - const code = - err instanceof jwt.TokenExpiredError - ? "TOKEN_EXPIRED" - : err instanceof jwt.NotBeforeError - ? "TOKEN_NOT_ACTIVE" - : "INVALID_TOKEN"; - - logger.warn("[requireAuth] JWT verification failed", { code }); - next( - new UnauthorizedError( - code === "TOKEN_EXPIRED" ? "Token expired" : "Invalid token", - code, + const payload = decoded as Record; + const uid = payload.userId || payload.sub; + + if (typeof uid !== "string" || uid.trim() === "") { + logger.warn("[requireAuth] Token missing required userId or sub claim"); + return { + error: new UnauthorizedError( + "Token missing required claims", + "MISSING_CLAIMS", ), - ); - return; + }; } - } else { - next(new UnauthorizedError("Invalid authorization header", "INVALID_AUTH_HEADER")); - return; + + return { userId: uid }; + } catch (err) { + const code = + err instanceof jwt.TokenExpiredError + ? "TOKEN_EXPIRED" + : err instanceof jwt.NotBeforeError + ? "TOKEN_NOT_ACTIVE" + : "INVALID_TOKEN"; + + logger.warn("[requireAuth] JWT verification failed", { code }); + return { + error: new UnauthorizedError( + code === "TOKEN_EXPIRED" ? "Token expired" : "Invalid token", + code, + ), + }; } - } else { - const forwardedUserId = req.header("x-user-id"); - userId = forwardedUserId?.trim(); + } + + const forwardedUserId = req.header("x-user-id")?.trim(); + return forwardedUserId ? { userId: forwardedUserId } : {}; +} + +export const requireAuth = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + const { userId, error } = resolveRequestUserId(req); + if (error) { + next(error); + return; } if (!userId) { diff --git a/src/middleware/restRateLimit.test.ts b/src/middleware/restRateLimit.test.ts new file mode 100644 index 0000000..ce89a23 --- /dev/null +++ b/src/middleware/restRateLimit.test.ts @@ -0,0 +1,93 @@ +import express from 'express'; +import request from 'supertest'; +import { errorHandler } from './errorHandler.js'; +import { createRestRateLimitMiddleware } from './restRateLimit.js'; +import { requireAuth, type AuthenticatedLocals } from './requireAuth.js'; +import { TEST_JWT_SECRET, signTestToken } from '../../tests/helpers/jwt.js'; + +function buildProtectedApp() { + const app = express(); + const restRateLimit = createRestRateLimitMiddleware({ + windowMs: 60_000, + maxRequests: 2, + }); + + app.get( + '/protected', + restRateLimit, + requireAuth, + (_req, res: express.Response) => { + res.json({ ok: true, userId: res.locals.authenticatedUser?.id }); + }, + ); + + app.use(errorHandler); + return app; +} + +describe('restRateLimit middleware', () => { + const originalSecret = process.env.JWT_SECRET; + + beforeEach(() => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + }); + + afterEach(() => { + if (originalSecret !== undefined) { + process.env.JWT_SECRET = originalSecret; + } else { + delete process.env.JWT_SECRET; + } + }); + + test('returns 429 with Retry-After after the per-user limit is exceeded', async () => { + const app = buildProtectedApp(); + + await request(app).get('/protected').set('x-user-id', 'user-1').expect(200); + await request(app).get('/protected').set('x-user-id', 'user-1').expect(200); + const response = await request(app).get('/protected').set('x-user-id', 'user-1'); + + expect(response.status).toBe(429); + expect(response.body.code).toBe('TOO_MANY_REQUESTS'); + expect(response.headers['retry-after']).toBe('60'); + }); + + test('tracks limits separately per authenticated user id', async () => { + const app = buildProtectedApp(); + + await request(app).get('/protected').set('x-user-id', 'user-1').expect(200); + await request(app).get('/protected').set('x-user-id', 'user-1').expect(200); + await request(app).get('/protected').set('x-user-id', 'user-2').expect(200); + await request(app).get('/protected').set('x-user-id', 'user-2').expect(200); + + await request(app).get('/protected').set('x-user-id', 'user-1').expect(429); + await request(app).get('/protected').set('x-user-id', 'user-2').expect(429); + }); + + test('shares the same bucket across valid auth methods for the same user id', async () => { + const app = buildProtectedApp(); + const token = signTestToken({ + userId: 'user-1', + walletAddress: 'GDTEST123STELLAR', + }); + + await request(app).get('/protected').set('Authorization', `Bearer ${token}`).expect(200); + await request(app).get('/protected').set('x-user-id', 'user-1').expect(200); + const response = await request(app).get('/protected').set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(429); + expect(response.headers['retry-after']).toBe('60'); + }); + + test('falls back to IP-based limiting for unauthenticated requests', async () => { + const app = buildProtectedApp(); + + await request(app).get('/protected').expect(401); + await request(app).get('/protected').expect(401); + const response = await request(app).get('/protected'); + + expect(response.status).toBe(429); + expect(response.body.code).toBe('TOO_MANY_REQUESTS'); + expect(response.headers['retry-after']).toBe('60'); + }); +}); diff --git a/src/middleware/restRateLimit.ts b/src/middleware/restRateLimit.ts new file mode 100644 index 0000000..caea7bf --- /dev/null +++ b/src/middleware/restRateLimit.ts @@ -0,0 +1,93 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { config } from '../config/index.js'; +import { TooManyRequestsError } from '../errors/index.js'; +import { getClientIp } from '../lib/clientIp.js'; +import { resolveRequestUserId } from './requireAuth.js'; + +interface RateLimitBucket { + count: number; + resetAt: number; +} + +interface RateLimitCheckResult { + allowed: boolean; + retryAfterMs?: number; +} + +export interface RestRateLimitOptions { + windowMs: number; + maxRequests: number; +} + +export class InMemoryRestRateLimiter { + private readonly buckets = new Map(); + + constructor( + private readonly windowMs: number, + private readonly maxRequests: number, + ) {} + + check(key: string, now = Date.now()): RateLimitCheckResult { + const bucket = this.buckets.get(key); + + if (!bucket || now >= bucket.resetAt) { + this.buckets.set(key, { + count: 1, + resetAt: now + this.windowMs, + }); + return { allowed: true }; + } + + if (bucket.count >= this.maxRequests) { + return { + allowed: false, + retryAfterMs: Math.max(bucket.resetAt - now, 0), + }; + } + + bucket.count += 1; + return { allowed: true }; + } + + reset(): void { + this.buckets.clear(); + } +} + +export function getRestRateLimitKey(req: Request): string { + const { userId } = resolveRequestUserId(req); + if (userId) { + return `user:${userId}`; + } + + return `ip:${getClientIp(req)}`; +} + +export function createRestRateLimitMiddleware( + options: RestRateLimitOptions, + rateLimiter = new InMemoryRestRateLimiter(options.windowMs, options.maxRequests), +): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const key = getRestRateLimitKey(req); + const result = rateLimiter.check(key); + + if (!result.allowed) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((result.retryAfterMs ?? options.windowMs) / 1000), + ); + res.set('Retry-After', String(retryAfterSeconds)); + next(new TooManyRequestsError('Too Many Requests')); + return; + } + + next(); + }; +} + +export function createConfiguredRestRateLimitMiddleware(): RequestHandler { + return createRestRateLimitMiddleware({ + windowMs: config.restRateLimit.windowMs, + maxRequests: config.restRateLimit.maxRequests, + }); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 3649e99..0be483b 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,11 +2,27 @@ import { Router } from 'express'; import healthRouter from './health.js'; import usageRouter from './usage.js'; import billingRouter from './billing.js'; +import type { RequestHandler } from 'express'; -const router = Router(); +export interface ApiRouterDeps { + restRateLimit?: RequestHandler; +} router.use('/health', healthRouter); router.use('/usage', usageRouter); router.use('/billing', billingRouter); -export default router; + router.use('/health', healthRouter); + router.use('/apis', apisRouter); + router.use('/usage', usageRouter); + + if (deps.restRateLimit) { + router.use('/billing', deps.restRateLimit, billingRouter); + } else { + router.use('/billing', billingRouter); + } + + return router; +} + +export default createApiRouter();