diff --git a/.env.example b/.env.example index 81bddf9..9a49163 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,17 @@ NODE_ENV=development DATABASE_URL=postgresql://postgres:password@localhost:5432/orbitstream # JWT +# REQUIRED in production. Must be a unique, high-entropy value of at least 32 +# characters. The app refuses to start in production if JWT_SECRET is unset, still +# set to a placeholder ("dev-secret" / "change-me-in-production"), or shorter than +# 32 characters. Generate one with: openssl rand -base64 48 JWT_SECRET=change-me-in-production JWT_EXPIRES_IN=7d +# Optional: previous secret used during zero-downtime rotation. Tokens signed with +# either JWT_SECRET or JWT_SECRET_PREVIOUS are accepted; tokens that only verify +# against the previous secret are re-issued with the current secret on /auth/refresh +# (or any login). Remove this once all old tokens have expired. +JWT_SECRET_PREVIOUS= # Stellar STELLAR_NETWORK=TESTNET diff --git a/load-tests/rate-limit.artillery.yml b/load-tests/rate-limit.artillery.yml new file mode 100644 index 0000000..750989b --- /dev/null +++ b/load-tests/rate-limit.artillery.yml @@ -0,0 +1,38 @@ +# Rate-limiting load test (optional). +# +# Run with the Artillery CLI without adding it as a project dependency: +# npx artillery@latest run load-tests/rate-limit.artillery.yml +# +# Goal: hammer a per-IP limited endpoint with ~100 concurrent virtual users and +# confirm the limiter holds — responses should be a mix of 200 (within limit) and +# 429 (Too Many Requests) rather than the server accepting everything. +# +# Note: Artillery is intentionally NOT a package.json dependency to keep the +# install lean; install it on demand via npx. See the PR notes — full load +# verification is left as a follow-up. + +config: + target: 'http://localhost:3001' + phases: + - name: burst + duration: 30 + arrivalRate: 100 # 100 new virtual users per second + http: + timeout: 10 + # Surface the rate-limit headers in the report. + ensure: + maxErrorRate: 100 + +scenarios: + - name: 'checkout status reads (60/min per IP)' + flow: + - get: + url: '/v1/checkout/sessions/load-test-session-id' + # All requests share an IP so the per-IP sliding window trips quickly. + headers: + X-Forwarded-For: '198.51.100.10' + expect: + - statusCode: + - 200 + - 404 + - 429 diff --git a/src/__tests__/rate-limit.integration.spec.ts b/src/__tests__/rate-limit.integration.spec.ts new file mode 100644 index 0000000..99f34db --- /dev/null +++ b/src/__tests__/rate-limit.integration.spec.ts @@ -0,0 +1,61 @@ +import express from 'express'; +import request from 'supertest'; +import RedisMock from 'ioredis-mock'; +import { RateLimitMiddleware } from '../api/middleware/rate-limit.middleware'; +import { RateLimitService } from '../api/middleware/rate-limit.service'; +import type { RedisService } from '../redis/redis.service'; + +/** + * End-to-end style test: mount the real rate-limit middleware on an Express app + * (the same primitives Nest uses under the hood) and drive it with supertest to + * confirm a 429 is returned once the limit is exceeded, that the documented + * headers are present, and that exempt routes are never throttled. + */ +function buildApp() { + const client = new RedisMock(); + const redisService = { getClient: () => client } as unknown as RedisService; + const service = new RateLimitService(redisService); + const limiter = new RateLimitMiddleware(service); + jest.spyOn((limiter as any).logger, 'warn').mockImplementation(() => undefined); + + const app = express(); + app.set('trust proxy', 1); + app.use((req, res, next) => limiter.use(req, res, next)); + app.get('/health', (_req, res) => res.json({ ok: true })); + app.post('/merchants/register', (_req, res) => res.json({ ok: true })); + app.get('/v1/checkout/sessions/:id', (_req, res) => res.json({ ok: true })); + return app; +} + +describe('rate limiting (integration)', () => { + it('returns 429 with Retry-After when the per-IP limit is exceeded', async () => { + const app = buildApp(); + const agent = request(app); + const ip = '203.0.113.7'; + + // /merchants/register limit is 3/min per IP. + for (let i = 0; i < 3; i++) { + const ok = await agent.post('/merchants/register').set('X-Forwarded-For', ip); + expect(ok.status).toBe(200); + expect(ok.headers['x-ratelimit-limit']).toBe('3'); + } + + const blocked = await agent.post('/merchants/register').set('X-Forwarded-For', ip); + expect(blocked.status).toBe(429); + expect(blocked.body).toMatchObject({ statusCode: 429, error: 'Too Many Requests' }); + expect(blocked.headers['retry-after']).toBeDefined(); + expect(Number(blocked.headers['retry-after'])).toBeGreaterThanOrEqual(1); + expect(blocked.headers['x-ratelimit-remaining']).toBe('0'); + }); + + it('exempts /health from rate limiting', async () => { + const app = buildApp(); + const agent = request(app); + // Well above the 60/min default limit — proves /health is never throttled. + for (let i = 0; i < 80; i++) { + const res = await agent.get('/health'); + expect(res.status).toBe(200); + expect(res.headers['x-ratelimit-limit']).toBeUndefined(); + } + }, 15000); +}); diff --git a/src/api/middleware/rate-limit.config.spec.ts b/src/api/middleware/rate-limit.config.spec.ts new file mode 100644 index 0000000..b919e2d --- /dev/null +++ b/src/api/middleware/rate-limit.config.spec.ts @@ -0,0 +1,98 @@ +import type { Request } from 'express'; +import { isExempt, resolveRule, clientIp, apiKey, rateLimitIdentity } from './rate-limit.config'; + +describe('isExempt', () => { + it.each(['/health', '/metrics', '/health/db', '/metrics/'])('exempts %s', (path) => { + expect(isExempt(path)).toBe(true); + }); + + it.each(['/auth/login', '/v1/checkout/sessions', '/healthz', '/metricsfoo'])( + 'does not exempt %s', + (path) => { + expect(isExempt(path)).toBe(false); + }, + ); +}); + +describe('resolveRule', () => { + it('limits /auth/login and /auth/verify to 5/min per IP', () => { + expect(resolveRule('POST', '/auth/login')).toMatchObject({ limit: 5, scope: 'ip' }); + expect(resolveRule('POST', '/auth/verify')).toMatchObject({ limit: 5, scope: 'ip' }); + }); + + it('limits /merchants/register to 3/min per IP', () => { + expect(resolveRule('POST', '/merchants/register')).toMatchObject({ limit: 3, scope: 'ip' }); + }); + + it('limits POST /v1/checkout/sessions to 100/min per API key', () => { + expect(resolveRule('POST', '/v1/checkout/sessions')).toMatchObject({ + limit: 100, + scope: 'apiKey', + }); + }); + + it('limits GET /v1/checkout/sessions/:id to 60/min per IP', () => { + expect(resolveRule('GET', '/v1/checkout/sessions/abc')).toMatchObject({ + limit: 60, + scope: 'ip', + }); + }); + + it('falls back to 60/min per IP for everything else', () => { + expect(resolveRule('GET', '/merchants/me')).toMatchObject({ limit: 60, scope: 'ip' }); + expect(resolveRule('POST', '/auth/challenge')).toMatchObject({ limit: 60, scope: 'ip' }); + }); +}); + +function req(headers: Record, ip?: string): Request { + return { headers, ip, socket: { remoteAddress: ip } } as unknown as Request; +} + +describe('clientIp', () => { + it('prefers the first X-Forwarded-For hop', () => { + expect(clientIp(req({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }))).toBe('1.2.3.4'); + }); + + it('falls back to req.ip', () => { + expect(clientIp(req({}, '9.9.9.9'))).toBe('9.9.9.9'); + }); +}); + +describe('apiKey', () => { + it('extracts the bearer token', () => { + expect(apiKey(req({ authorization: 'Bearer sk_test_abc' }))).toBe('sk_test_abc'); + }); + + it('returns undefined without a bearer token', () => { + expect(apiKey(req({}))).toBeUndefined(); + }); +}); + +describe('rateLimitIdentity', () => { + it('keys api-key-scoped rules by the api key', () => { + const id = rateLimitIdentity(req({ authorization: 'Bearer sk_test_abc' }), { + name: 'checkout-create', + limit: 100, + scope: 'apiKey', + }); + expect(id).toBe('key:sk_test_abc'); + }); + + it('falls back to IP when an api-key-scoped rule has no key', () => { + const id = rateLimitIdentity(req({}, '1.1.1.1'), { + name: 'checkout-create', + limit: 100, + scope: 'apiKey', + }); + expect(id).toBe('ip:1.1.1.1'); + }); + + it('keys ip-scoped rules by IP', () => { + const id = rateLimitIdentity(req({ 'x-forwarded-for': '2.2.2.2' }), { + name: 'auth', + limit: 5, + scope: 'ip', + }); + expect(id).toBe('ip:2.2.2.2'); + }); +}); diff --git a/src/api/middleware/rate-limit.config.ts b/src/api/middleware/rate-limit.config.ts new file mode 100644 index 0000000..5fba8d8 --- /dev/null +++ b/src/api/middleware/rate-limit.config.ts @@ -0,0 +1,89 @@ +import type { Request } from 'express'; + +export const WINDOW_MS = 60_000; // 1 minute sliding window + +export type RateLimitScope = 'ip' | 'apiKey'; + +export interface RateLimitRule { + /** Human-readable identifier used in the Redis key namespace. */ + name: string; + /** Allowed requests per window. */ + limit: number; + /** Whether the limit is keyed by client IP or by API key. */ + scope: RateLimitScope; +} + +/** Endpoints that bypass rate limiting entirely. */ +const EXEMPT_PREFIXES = ['/health', '/metrics']; + +export function isExempt(path: string): boolean { + return EXEMPT_PREFIXES.some((p) => path === p || path.startsWith(p + '/')); +} + +const GET_CHECKOUT_SESSION = /^\/v1\/checkout\/sessions\/[^/]+\/?$/; +const CHECKOUT_SESSIONS = /^\/v1\/checkout\/sessions\/?$/; + +/** + * Resolve the rate-limit rule for a request. + * + * Order matters: more specific routes are matched before the catch-all. + */ +export function resolveRule(method: string, path: string): RateLimitRule { + const m = method.toUpperCase(); + + // Auth: tight per-IP limit on credential endpoints. + if (path === '/auth/login' || path === '/auth/verify') { + return { name: 'auth', limit: 5, scope: 'ip' }; + } + + // Merchant registration: very tight per-IP limit (anti-abuse). + if (path === '/merchants/register') { + return { name: 'merchants-register', limit: 3, scope: 'ip' }; + } + + // Checkout session creation: per-API-key (programmatic, higher limit). + if (m === 'POST' && CHECKOUT_SESSIONS.test(path)) { + return { name: 'checkout-create', limit: 100, scope: 'apiKey' }; + } + + // Public checkout-status reads: per-IP. + if (m === 'GET' && GET_CHECKOUT_SESSION.test(path)) { + return { name: 'checkout-get', limit: 60, scope: 'ip' }; + } + + // Default: per-IP. + return { name: 'default', limit: 60, scope: 'ip' }; +} + +/** Extract the client IP, honouring the first `X-Forwarded-For` hop. */ +export function clientIp(req: Request): string { + const fwd = req.headers['x-forwarded-for']; + if (typeof fwd === 'string' && fwd.length > 0) { + return fwd.split(',')[0].trim(); + } + if (Array.isArray(fwd) && fwd.length > 0) { + return fwd[0].split(',')[0].trim(); + } + return req.ip ?? req.socket?.remoteAddress ?? 'unknown'; +} + +/** Extract the API key (bearer token) used to key API-scoped limits. */ +export function apiKey(req: Request): string | undefined { + const auth = req.headers['authorization']; + if (typeof auth === 'string' && auth.startsWith('Bearer ')) { + return auth.slice(7); + } + return undefined; +} + +/** + * Build the rate-limit identity for a request. API-key-scoped rules fall back to + * IP when no key is present so unauthenticated callers are still limited. + */ +export function rateLimitIdentity(req: Request, rule: RateLimitRule): string { + if (rule.scope === 'apiKey') { + const key = apiKey(req); + if (key) return `key:${key}`; + } + return `ip:${clientIp(req)}`; +} diff --git a/src/api/middleware/rate-limit.middleware.spec.ts b/src/api/middleware/rate-limit.middleware.spec.ts new file mode 100644 index 0000000..05d26e4 --- /dev/null +++ b/src/api/middleware/rate-limit.middleware.spec.ts @@ -0,0 +1,94 @@ +import type { Request, Response } from 'express'; +import RedisMock from 'ioredis-mock'; +import { RateLimitMiddleware } from './rate-limit.middleware'; +import { RateLimitService } from './rate-limit.service'; +import type { RedisService } from '../../redis/redis.service'; + +function buildMiddleware() { + const client = new RedisMock(); + const redisService = { getClient: () => client } as unknown as RedisService; + const service = new RateLimitService(redisService); + const middleware = new RateLimitMiddleware(service); + // Silence the breach warning. + jest.spyOn((middleware as any).logger, 'warn').mockImplementation(() => undefined); + return { middleware, client }; +} + +function mockRes(): { + res: Response; + headers: Record; + status: jest.Mock; + json: jest.Mock; +} { + const headers: Record = {}; + const status = jest.fn().mockReturnThis(); + const json = jest.fn().mockReturnThis(); + const res: any = { + setHeader: (name: string, value: unknown) => { + headers[name] = String(value); + return res; + }, + status, + json, + }; + return { res: res as Response, headers, status, json }; +} + +function mockReq(method: string, path: string, ip = '1.1.1.1'): Request { + return { + method, + path, + ip, + headers: { 'x-forwarded-for': ip }, + socket: { remoteAddress: ip }, + } as unknown as Request; +} + +describe('RateLimitMiddleware', () => { + it('skips exempt routes without setting headers', async () => { + const { middleware } = buildMiddleware(); + const { res, headers } = mockRes(); + const next = jest.fn(); + await middleware.use(mockReq('GET', '/health'), res, next); + expect(next).toHaveBeenCalled(); + expect(headers['X-RateLimit-Limit']).toBeUndefined(); + }); + + it('sets X-RateLimit headers and calls next within the limit', async () => { + const { middleware } = buildMiddleware(); + const { res, headers } = mockRes(); + const next = jest.fn(); + await middleware.use(mockReq('POST', '/auth/login'), res, next); + + expect(next).toHaveBeenCalled(); + expect(headers['X-RateLimit-Limit']).toBe('5'); + expect(headers['X-RateLimit-Remaining']).toBe('4'); + expect(headers['X-RateLimit-Reset']).toBeDefined(); + }); + + it('returns 429 with Retry-After once the limit is exceeded', async () => { + const { middleware } = buildMiddleware(); + const ip = '9.9.9.9'; + + // /merchants/register has a limit of 3. + for (let i = 0; i < 3; i++) { + const { res } = mockRes(); + const next = jest.fn(); + await middleware.use(mockReq('POST', '/merchants/register', ip), res, next); + expect(next).toHaveBeenCalled(); + } + + const { res, headers, status, json } = mockRes(); + const next = jest.fn(); + await middleware.use(mockReq('POST', '/merchants/register', ip), res, next); + + expect(next).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(429); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 429, error: 'Too Many Requests' }), + ); + expect(headers['Retry-After']).toBeDefined(); + expect(Number(headers['Retry-After'])).toBeGreaterThanOrEqual(1); + expect(headers['X-RateLimit-Remaining']).toBe('0'); + }); +}); diff --git a/src/api/middleware/rate-limit.middleware.ts b/src/api/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..030de2e --- /dev/null +++ b/src/api/middleware/rate-limit.middleware.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; +import type { Request, Response, NextFunction } from 'express'; +import { RateLimitService } from './rate-limit.service'; +import { isExempt, rateLimitIdentity, resolveRule } from './rate-limit.config'; + +/** + * Global sliding-window rate-limiting middleware. + * + * - Resolves the per-endpoint rule (limit + scope) and identity (IP or API key). + * - Skips `/health` and `/metrics`. + * - Halves the limit when running in the degraded (in-memory) fallback. + * - Emits `X-RateLimit-*` headers, and on breach returns `429` with `Retry-After`. + */ +@Injectable() +export class RateLimitMiddleware implements NestMiddleware { + private readonly logger = new Logger(RateLimitMiddleware.name); + + constructor(private readonly rateLimiter: RateLimitService) {} + + async use(req: Request, res: Response, next: NextFunction): Promise { + if (isExempt(req.path)) { + return next(); + } + + const rule = resolveRule(req.method, req.path); + const identity = rateLimitIdentity(req, rule); + const keyspace = `${rule.name}:${identity}`; + + // The service applies the configured limit when Redis is healthy and + // automatically falls back to 50% of the limit (in-memory) when it is not. + const result = await this.rateLimiter.check(keyspace, rule.limit); + + res.setHeader('X-RateLimit-Limit', String(result.limit)); + res.setHeader('X-RateLimit-Remaining', String(result.remaining)); + res.setHeader('X-RateLimit-Reset', String(result.resetSeconds)); + + if (!result.allowed) { + res.setHeader('Retry-After', String(result.retryAfterSeconds)); + this.logger.warn(`Rate limit exceeded for ${keyspace} (limit ${result.limit}/min)`); + res.status(429).json({ + statusCode: 429, + error: 'Too Many Requests', + message: 'Rate limit exceeded. Please retry later.', + retryAfter: result.retryAfterSeconds, + }); + return; + } + + next(); + } +} diff --git a/src/api/middleware/rate-limit.module.ts b/src/api/middleware/rate-limit.module.ts new file mode 100644 index 0000000..859e616 --- /dev/null +++ b/src/api/middleware/rate-limit.module.ts @@ -0,0 +1,18 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { RateLimitService } from './rate-limit.service'; +import { RateLimitMiddleware } from './rate-limit.middleware'; + +/** + * Wires the sliding-window rate limiter into the request pipeline for all routes. + * Exemptions (`/health`, `/metrics`) are handled inside the middleware itself so + * the routing config stays simple and the exempt list lives next to the rules. + */ +@Module({ + providers: [RateLimitService, RateLimitMiddleware], + exports: [RateLimitService], +}) +export class RateLimitModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(RateLimitMiddleware).forRoutes('*'); + } +} diff --git a/src/api/middleware/rate-limit.service.spec.ts b/src/api/middleware/rate-limit.service.spec.ts new file mode 100644 index 0000000..f397c6a --- /dev/null +++ b/src/api/middleware/rate-limit.service.spec.ts @@ -0,0 +1,109 @@ +import RedisMock from 'ioredis-mock'; +import { RateLimitService } from './rate-limit.service'; +import type { RedisService } from '../../redis/redis.service'; + +function serviceWithClient(client: any): RateLimitService { + const redisService = { getClient: () => client } as unknown as RedisService; + return new RateLimitService(redisService); +} + +describe('RateLimitService (Redis backend)', () => { + let client: any; + let service: RateLimitService; + + beforeEach(() => { + client = new RedisMock(); + service = serviceWithClient(client); + }); + + afterEach(async () => { + await client.flushall(); + }); + + it('allows requests within the limit and decrements remaining', async () => { + const a = await service.check('test:ip:1.1.1.1', 3); + expect(a.allowed).toBe(true); + expect(a.limit).toBe(3); + expect(a.remaining).toBe(2); + expect(a.degraded).toBe(false); + + const b = await service.check('test:ip:1.1.1.1', 3); + expect(b.allowed).toBe(true); + expect(b.remaining).toBe(1); + }); + + it('allows exactly up to the limit, then blocks (at limit / over limit)', async () => { + const results = []; + for (let i = 0; i < 4; i++) { + results.push(await service.check('test:ip:2.2.2.2', 3)); + } + expect(results.slice(0, 3).every((r) => r.allowed)).toBe(true); + expect(results[2].remaining).toBe(0); // at limit, last allowed request + expect(results[3].allowed).toBe(false); // over limit + expect(results[3].remaining).toBe(0); + expect(results[3].retryAfterSeconds).toBeGreaterThanOrEqual(1); + }); + + it('keys independently per identity', async () => { + await service.check('test:ip:a', 1); + const blockedA = await service.check('test:ip:a', 1); + const allowedB = await service.check('test:ip:b', 1); + expect(blockedA.allowed).toBe(false); + expect(allowedB.allowed).toBe(true); + }); + + it('exposes a reset timestamp roughly one window in the future', async () => { + const r = await service.check('test:ip:reset', 5); + const nowSec = Math.floor(Date.now() / 1000); + expect(r.resetSeconds).toBeGreaterThan(nowSec); + expect(r.resetSeconds).toBeLessThanOrEqual(nowSec + 61); + }); +}); + +describe('RateLimitService (graceful degradation)', () => { + it('falls back to in-memory limiting at 50% when no client is available', async () => { + const service = serviceWithClient(null); + const warn = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); + + // Normal limit 4 -> degraded limit 2. + const a = await service.check('deg:ip:1', 4); + const b = await service.check('deg:ip:1', 4); + const c = await service.check('deg:ip:1', 4); + + expect(a.degraded).toBe(true); + expect(a.limit).toBe(2); + expect(a.allowed).toBe(true); + expect(b.allowed).toBe(true); + expect(c.allowed).toBe(false); // blocked at the halved limit + warn.mockRestore(); + }); + + it('falls back when the client throws on eval and logs a warning', async () => { + const throwingClient = { + status: 'ready', + eval: jest.fn().mockRejectedValue(new Error('connection refused')), + }; + const service = serviceWithClient(throwingClient); + const warn = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); + + const r = await service.check('deg:ip:throw', 10); + expect(r.degraded).toBe(true); + expect(r.limit).toBe(5); // 50% of 10 + expect(r.allowed).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('falling back to in-memory')); + warn.mockRestore(); + }); + + it('treats a non-ready client status as unavailable', async () => { + const notReady = { + status: 'connecting', + eval: jest.fn(), + }; + const service = serviceWithClient(notReady); + jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); + + const r = await service.check('deg:ip:notready', 8); + expect(r.degraded).toBe(true); + expect(notReady.eval).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/middleware/rate-limit.service.ts b/src/api/middleware/rate-limit.service.ts new file mode 100644 index 0000000..4d9a4bc --- /dev/null +++ b/src/api/middleware/rate-limit.service.ts @@ -0,0 +1,206 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { Redis } from 'ioredis'; +import { RedisService } from '../../redis/redis.service'; +import { WINDOW_MS } from './rate-limit.config'; + +export interface RateLimitResult { + /** Whether the request is allowed. */ + allowed: boolean; + /** The effective limit applied (may be reduced under degraded mode). */ + limit: number; + /** Remaining requests in the current window (never negative). */ + remaining: number; + /** Unix epoch seconds when the window resets / the oldest entry expires. */ + resetSeconds: number; + /** Seconds the client should wait before retrying (only set when blocked). */ + retryAfterSeconds: number; + /** True when the Redis backend was unavailable and the in-memory fallback ran. */ + degraded: boolean; +} + +/** + * Atomic sliding-window rate limiter (Redis sorted sets). + * + * The Lua script runs the whole check-and-increment in one round trip so + * concurrent requests cannot race past the limit: + * 1. Drop entries older than the window (ZREMRANGEBYSCORE). + * 2. Count remaining entries (ZCARD). + * 3. If under the limit, add the current request (ZADD) and refresh the TTL. + * 4. Return [allowed, count, oldestScore]. + */ +const SLIDING_WINDOW_LUA = ` +local key = KEYS[1] +local now = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local limit = tonumber(ARGV[3]) +local member = ARGV[4] + +redis.call('ZREMRANGEBYSCORE', key, 0, now - window) +local count = redis.call('ZCARD', key) + +local allowed = 0 +if count < limit then + redis.call('ZADD', key, now, member) + count = count + 1 + allowed = 1 +end + +redis.call('PEXPIRE', key, window) + +local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') +local oldestScore = now +if oldest[2] then + oldestScore = tonumber(oldest[2]) +end + +return { allowed, count, oldestScore } +`; + +interface MemoryEntry { + timestamps: number[]; +} + +@Injectable() +export class RateLimitService { + private readonly logger = new Logger(RateLimitService.name); + private readonly memoryStore = new Map(); + /** Throttle the "Redis unavailable" warning so we don't flood the logs. */ + private lastDegradedWarn = 0; + + constructor(private readonly redisService: RedisService) {} + + /** + * Check (and, if allowed, record) a request against its limit. + * + * `keyspace` is the full identity (rule name + scope identity) used as the + * Redis/memory key. `limit` is the normal limit; `degradedLimit` (defaults to + * 50% of `limit`) is applied automatically when the Redis backend is + * unavailable and the in-memory fallback runs. Passing both up front means the + * request is recorded exactly once, with the correct limit, in a single pass. + */ + async check(keyspace: string, limit: number, degradedLimit?: number): Promise { + const now = Date.now(); + const fallbackLimit = degradedLimit ?? Math.max(1, Math.floor(limit / 2)); + try { + const client = this.getReadyClient(); + if (!client) { + return this.checkMemory(keyspace, fallbackLimit, now); + } + return await this.checkRedis(client, keyspace, limit, now); + } catch (err) { + this.warnDegraded(err); + return this.checkMemory(keyspace, fallbackLimit, now); + } + } + + private getReadyClient(): Redis | null { + const client = this.redisService.getClient(); + if (!client) return null; + // ioredis exposes `status`; only use it when actually connected. + if (client.status && client.status !== 'ready') { + return null; + } + return client; + } + + private async checkRedis( + client: Redis, + keyspace: string, + limit: number, + now: number, + ): Promise { + const member = `${now}:${Math.random().toString(36).slice(2, 10)}`; + const raw = (await client.eval( + SLIDING_WINDOW_LUA, + 1, + `ratelimit:${keyspace}`, + String(now), + String(WINDOW_MS), + String(limit), + member, + )) as [number, number, number]; + + const allowed = raw[0] === 1; + const count = raw[1]; + const oldestScore = raw[2]; + + return this.buildResult({ + allowed, + count, + limit, + oldestScore, + now, + degraded: false, + }); + } + + /** + * In-memory sliding window. Used when Redis is unavailable; the limit is halved + * by the caller (degraded mode) to stay conservative across multiple instances. + */ + private checkMemory(keyspace: string, limit: number, now: number): RateLimitResult { + const windowStart = now - WINDOW_MS; + const entry = this.memoryStore.get(keyspace) ?? { timestamps: [] }; + entry.timestamps = entry.timestamps.filter((t) => t > windowStart); + + const count = entry.timestamps.length; + let allowed = false; + if (count < limit) { + entry.timestamps.push(now); + allowed = true; + } + this.memoryStore.set(keyspace, entry); + + const oldestScore = entry.timestamps.length > 0 ? entry.timestamps[0] : now; + + return this.buildResult({ + allowed, + count: allowed ? count + 1 : count, + limit, + oldestScore, + now, + degraded: true, + }); + } + + private buildResult(args: { + allowed: boolean; + count: number; + limit: number; + oldestScore: number; + now: number; + degraded: boolean; + }): RateLimitResult { + const { allowed, count, limit, oldestScore, now, degraded } = args; + const remaining = Math.max(0, limit - count); + // The window resets when the oldest in-window request ages out. + const resetMs = oldestScore + WINDOW_MS; + const resetSeconds = Math.ceil(resetMs / 1000); + const retryAfterSeconds = allowed ? 0 : Math.max(1, Math.ceil((resetMs - now) / 1000)); + + return { + allowed, + limit, + remaining, + resetSeconds, + retryAfterSeconds, + degraded, + }; + } + + private warnDegraded(err: unknown): void { + const now = Date.now(); + if (now - this.lastDegradedWarn > 10_000) { + this.lastDegradedWarn = now; + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Redis unavailable for rate limiting, falling back to in-memory limiting at 50% capacity: ${message}`, + ); + } + } + + /** Test helper: clear the in-memory store. */ + resetMemory(): void { + this.memoryStore.clear(); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index bf76a69..1001119 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { StellarModule } from './stellar/stellar.module'; import { WebhookModule } from './webhook/webhook.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { RedisModule } from './redis/redis.module'; +import { RateLimitModule } from './api/middleware/rate-limit.module'; import { DynamicCorsMiddleware } from './middleware/dynamic-cors.middleware'; import { SecurityHeadersMiddleware } from './middleware/security-headers.middleware'; @@ -20,6 +21,7 @@ import { SecurityHeadersMiddleware } from './middleware/security-headers.middlew synchronize: process.env.NODE_ENV !== 'production', }), RedisModule, + RateLimitModule, AuthModule, MerchantsModule, CheckoutModule, diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index f0eacf8..0032339 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Request } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; import { AuthService } from './auth.service'; import { WalletLoginDto, RequestChallengeDto, VerifyChallengeDto } from './auth.dto'; @@ -23,4 +24,15 @@ export class AuthController { login(@Body() dto: WalletLoginDto) { return this.auth.walletLogin(dto); } + + /** + * Exchange a valid (possibly previous-secret) token for a token signed with the + * current secret. Enables zero-downtime JWT secret rotation. + */ + @UseGuards(AuthGuard('jwt')) + @Post('refresh') + @HttpCode(HttpStatus.OK) + refresh(@Request() req: any) { + return this.auth.refresh(req.user); + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index e882665..18289eb 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,13 +4,21 @@ import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; +import { resolveJwtSecrets, jwtExpiresIn } from '../config/jwt-secret.config'; @Module({ imports: [ PassportModule, - JwtModule.register({ - secret: process.env.JWT_SECRET ?? 'dev-secret', - signOptions: { expiresIn: process.env.JWT_EXPIRES_IN ?? '7d' }, + JwtModule.registerAsync({ + // Resolve the secret lazily so validation runs against the live env and any + // misconfiguration surfaces during module init rather than at import time. + useFactory: () => { + const { current } = resolveJwtSecrets(); + return { + secret: current, + signOptions: { expiresIn: jwtExpiresIn() }, + }; + }, }), ], controllers: [AuthController], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 20efeaa..41f9ef6 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -301,4 +301,23 @@ export class AuthService { const payload = { sub: walletAddress, walletAddress, authMethod: 'wallet' }; return { access_token: this.jwt.sign(payload), wallet: walletAddress }; } + + /** + * Re-issue a token signed with the CURRENT secret. Used during secret rotation: + * a client whose token only verified against `JWT_SECRET_PREVIOUS` can exchange + * it for a fresh token without repeating the full challenge flow. `JwtService` + * is configured with the current secret, so the new token is always current. + */ + async refresh(user: { walletAddress: string; viaPreviousSecret?: boolean }) { + const { walletAddress } = user; + if (user.viaPreviousSecret) { + this.logger.log(`Re-issuing token under current secret for ${walletAddress} (rotation)`); + } + const payload = { sub: walletAddress, walletAddress, authMethod: 'refresh' }; + return { + access_token: this.jwt.sign(payload), + wallet: walletAddress, + rotated: user.viaPreviousSecret === true, + }; + } } diff --git a/src/auth/jwt.strategy.spec.ts b/src/auth/jwt.strategy.spec.ts new file mode 100644 index 0000000..b17e06d --- /dev/null +++ b/src/auth/jwt.strategy.spec.ts @@ -0,0 +1,86 @@ +import * as jwt from 'jsonwebtoken'; +import { JwtStrategy } from './jwt.strategy'; + +const CURRENT = 'current-secret-current-secret-1234'; // >= 32 chars +const PREVIOUS = 'previous-secret-previous-secret-9'; // >= 32 chars + +/** + * The strategy reads its secrets from env at verify-time via `resolveJwtSecrets`, + * so we exercise the `secretOrKeyProvider` directly to assert rotation behaviour + * without standing up Passport's full request pipeline. + */ +function provider(strategy: JwtStrategy) { + return (strategy as any)._secretOrKeyProvider as ( + req: any, + token: string, + done: (err: Error | null, secret?: string) => void, + ) => void; +} + +function resolveSecret( + strategy: JwtStrategy, + req: any, + token: string, +): Promise { + return new Promise((resolve, reject) => { + provider(strategy)(req, token, (err, secret) => (err ? reject(err) : resolve(secret))); + }); +} + +describe('JwtStrategy (rotation)', () => { + const origEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...origEnv }; + }); + + it('verifies a token signed with the current secret', async () => { + process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = CURRENT; + delete process.env.JWT_SECRET_PREVIOUS; + + const strategy = new JwtStrategy(); + const token = jwt.sign({ walletAddress: 'GABC' }, CURRENT); + const req: any = {}; + + const secret = await resolveSecret(strategy, req, token); + expect(secret).toBe(CURRENT); + expect(req.jwtViaPreviousSecret).toBeUndefined(); + + const result = await strategy.validate(req, { walletAddress: 'GABC' }); + expect(result).toEqual({ walletAddress: 'GABC', viaPreviousSecret: false }); + }); + + it('accepts a token signed with the previous secret and flags it for re-issue', async () => { + process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = CURRENT; + process.env.JWT_SECRET_PREVIOUS = PREVIOUS; + + const strategy = new JwtStrategy(); + const token = jwt.sign({ walletAddress: 'GXYZ' }, PREVIOUS); + const req: any = {}; + + const secret = await resolveSecret(strategy, req, token); + expect(secret).toBe(PREVIOUS); + expect(req.jwtViaPreviousSecret).toBe(true); + + const result = await strategy.validate(req, { walletAddress: 'GXYZ' }); + expect(result).toEqual({ walletAddress: 'GXYZ', viaPreviousSecret: true }); + }); + + it('rejects a token signed with an unknown secret', async () => { + process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = CURRENT; + process.env.JWT_SECRET_PREVIOUS = PREVIOUS; + + const strategy = new JwtStrategy(); + const token = jwt.sign({ walletAddress: 'GBAD' }, 'totally-different-secret-totally-diff'); + const req: any = {}; + + // Provider falls back to the current secret so passport-jwt rejects the + // signature; the previous-secret flag must NOT be set. + const secret = await resolveSecret(strategy, req, token); + expect(secret).toBe(CURRENT); + expect(req.jwtViaPreviousSecret).toBeUndefined(); + }); +}); diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 6a578df..63837d7 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -1,17 +1,64 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import * as jwt from 'jsonwebtoken'; +import { resolveJwtSecrets } from '../config/jwt-secret.config'; +/** + * JWT strategy with rotation support. + * + * Tokens are accepted if they verify against EITHER the current secret or the + * previous secret (`JWT_SECRET_PREVIOUS`). passport-jwt only verifies against a + * single key, so we use a `secretOrKeyProvider` that picks the key the token was + * actually signed with (current first, then previous). When the previous secret + * is used we stash a flag on the request so `validate` can expose + * `viaPreviousSecret`, letting the login flow re-issue the token with the current + * secret for zero-downtime rotation. + */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: process.env.JWT_SECRET ?? 'dev-secret', + passReqToCallback: true, + secretOrKeyProvider: ( + req: any, + rawJwtToken: string, + done: (err: Error | null, secret?: string) => void, + ) => { + try { + const { current, previous } = resolveJwtSecrets(); + // Verify against the current secret first. + try { + jwt.verify(rawJwtToken, current); + return done(null, current); + } catch { + // Fall through to the previous secret (rotation window). + } + if (previous) { + try { + jwt.verify(rawJwtToken, previous); + if (req) req.jwtViaPreviousSecret = true; + return done(null, previous); + } catch { + // Neither secret matched. + } + } + // Returning the current secret lets passport-jwt fail verification with + // its standard "invalid signature" error. + return done(null, current); + } catch (err) { + return done(err as Error); + } + }, }); } - async validate(payload: any) { - return { walletAddress: payload.walletAddress }; + + async validate(req: any, payload: any) { + return { + walletAddress: payload.walletAddress, + viaPreviousSecret: req?.jwtViaPreviousSecret === true, + }; } } diff --git a/src/config/jwt-secret.config.spec.ts b/src/config/jwt-secret.config.spec.ts new file mode 100644 index 0000000..1ab71d8 --- /dev/null +++ b/src/config/jwt-secret.config.spec.ts @@ -0,0 +1,100 @@ +import { + resolveJwtSecrets, + jwtExpiresIn, + MIN_JWT_SECRET_LENGTH, + INSECURE_JWT_SECRETS, +} from './jwt-secret.config'; + +const STRONG = 'a'.repeat(MIN_JWT_SECRET_LENGTH); // 32 chars +const STRONG_2 = 'b'.repeat(MIN_JWT_SECRET_LENGTH); + +function env(overrides: Record): NodeJS.ProcessEnv { + return { ...overrides } as NodeJS.ProcessEnv; +} + +describe('resolveJwtSecrets', () => { + describe('production hardening', () => { + it('throws when JWT_SECRET is missing in production', () => { + expect(() => resolveJwtSecrets(env({ NODE_ENV: 'production' }))).toThrow( + /JWT_SECRET is not set/, + ); + }); + + it('throws when JWT_SECRET is empty in production', () => { + expect(() => resolveJwtSecrets(env({ NODE_ENV: 'production', JWT_SECRET: '' }))).toThrow( + /JWT_SECRET is not set/, + ); + }); + + it.each(INSECURE_JWT_SECRETS)( + 'throws when JWT_SECRET is the insecure placeholder "%s" in production', + (placeholder) => { + expect(() => + resolveJwtSecrets(env({ NODE_ENV: 'production', JWT_SECRET: placeholder })), + ).toThrow(/insecure placeholder/); + }, + ); + + it('throws when JWT_SECRET is shorter than the minimum length', () => { + expect(() => + resolveJwtSecrets(env({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' })), + ).toThrow(new RegExp(`at least ${MIN_JWT_SECRET_LENGTH} characters`)); + }); + + it('accepts a strong secret in production', () => { + expect(resolveJwtSecrets(env({ NODE_ENV: 'production', JWT_SECRET: STRONG }))).toEqual({ + current: STRONG, + }); + }); + + it('throws when JWT_SECRET_PREVIOUS is too short in production', () => { + expect(() => + resolveJwtSecrets( + env({ NODE_ENV: 'production', JWT_SECRET: STRONG, JWT_SECRET_PREVIOUS: 'tooshort' }), + ), + ).toThrow(/JWT_SECRET_PREVIOUS must be at least/); + }); + + it('returns current + previous when both are strong', () => { + expect( + resolveJwtSecrets( + env({ NODE_ENV: 'production', JWT_SECRET: STRONG, JWT_SECRET_PREVIOUS: STRONG_2 }), + ), + ).toEqual({ current: STRONG, previous: STRONG_2 }); + }); + }); + + describe('length validation in all environments', () => { + it('throws for a too-short secret even in development', () => { + expect(() => + resolveJwtSecrets(env({ NODE_ENV: 'development', JWT_SECRET: 'short' })), + ).toThrow(new RegExp(`at least ${MIN_JWT_SECRET_LENGTH} characters`)); + }); + }); + + describe('development convenience', () => { + it('falls back to dev-secret when unset in development', () => { + expect(resolveJwtSecrets(env({ NODE_ENV: 'development' }))).toEqual({ + current: 'dev-secret', + }); + }); + + it('allows the placeholder secret in development', () => { + expect(resolveJwtSecrets(env({ NODE_ENV: 'development', JWT_SECRET: 'dev-secret' }))).toEqual( + { + current: 'dev-secret', + }, + ); + }); + }); +}); + +describe('jwtExpiresIn', () => { + it('defaults to 7d', () => { + expect(jwtExpiresIn(env({}))).toBe('7d'); + }); + + it('honours JWT_EXPIRES_IN', () => { + expect(jwtExpiresIn(env({ JWT_EXPIRES_IN: '1h' }))).toBe('1h'); + }); +}); diff --git a/src/config/jwt-secret.config.ts b/src/config/jwt-secret.config.ts new file mode 100644 index 0000000..79070de --- /dev/null +++ b/src/config/jwt-secret.config.ts @@ -0,0 +1,90 @@ +/** + * Centralised JWT secret resolution + hardening. + * + * Responsibilities: + * - Reject insecure secrets at startup (missing / dev placeholder / too short) + * when running in production. + * - Support zero-downtime secret rotation via `JWT_SECRET_PREVIOUS`. + * + * This module is intentionally framework-agnostic (no Nest decorators) so it can + * be imported from `main.ts`, the auth module and tests without bootstrapping the + * whole DI container. + */ + +export const MIN_JWT_SECRET_LENGTH = 32; + +/** Values that must never be used as a real secret. */ +export const INSECURE_JWT_SECRETS = ['dev-secret', 'change-me-in-production']; + +export interface ResolvedJwtSecrets { + /** The active secret used to sign newly issued tokens. */ + current: string; + /** The previous secret, accepted for verification during a rotation window. */ + previous?: string; +} + +function isProduction(env: NodeJS.ProcessEnv): boolean { + return env.NODE_ENV === 'production'; +} + +/** + * Validate the configured `JWT_SECRET` and (optional) `JWT_SECRET_PREVIOUS`. + * + * Throws an `Error` with an actionable message when the configuration is unsafe. + * In development a missing/insecure secret falls back to `dev-secret` so local + * workflows keep working, but in production every check is fatal. + */ +export function resolveJwtSecrets(env: NodeJS.ProcessEnv = process.env): ResolvedJwtSecrets { + const prod = isProduction(env); + const current = env.JWT_SECRET; + + if (!current || current.length === 0) { + if (prod) { + throw new Error( + 'JWT_SECRET is not set. A strong secret (>= ' + + MIN_JWT_SECRET_LENGTH + + ' characters) is required in production.', + ); + } + // Development convenience only. + return { current: 'dev-secret' }; + } + + if (INSECURE_JWT_SECRETS.includes(current)) { + if (prod) { + throw new Error( + `JWT_SECRET is still set to the insecure placeholder "${current}". ` + + 'Generate a unique secret before deploying to production (e.g. `openssl rand -base64 48`).', + ); + } + // Allow placeholders in development, but normalise to dev-secret. + return { current }; + } + + if (current.length < MIN_JWT_SECRET_LENGTH) { + // Length is a hard requirement in every environment so misconfiguration is + // caught long before it reaches production. + throw new Error( + `JWT_SECRET must be at least ${MIN_JWT_SECRET_LENGTH} characters long ` + + `(got ${current.length}).`, + ); + } + + const previous = env.JWT_SECRET_PREVIOUS; + if (previous && previous.length > 0) { + if (previous.length < MIN_JWT_SECRET_LENGTH && prod) { + throw new Error( + `JWT_SECRET_PREVIOUS must be at least ${MIN_JWT_SECRET_LENGTH} characters long ` + + `(got ${previous.length}).`, + ); + } + return { current, previous }; + } + + return { current }; +} + +/** The JWT token lifetime, defaulting to 7 days. */ +export function jwtExpiresIn(env: NodeJS.ProcessEnv = process.env): string { + return env.JWT_EXPIRES_IN ?? '7d'; +} diff --git a/src/main.ts b/src/main.ts index c30401a..c48a7a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,10 +2,18 @@ import 'dotenv/config'; import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; +import { resolveJwtSecrets } from './config/jwt-secret.config'; async function bootstrap() { + // Fail fast on insecure / missing JWT configuration before binding the port. + resolveJwtSecrets(); + const app = await NestFactory.create(AppModule); + // Trust the first proxy hop so client IPs (X-Forwarded-For) resolve correctly + // for rate limiting behind a load balancer. + app.getHttpAdapter().getInstance().set('trust proxy', 1); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); const port = Number(process.env.PORT ?? 3001);