diff --git a/.env.example b/.env.example index 8d84c5b..9912839 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ TRUSTFLOW_CONTRACT_ID= JWT_SECRET=change-me-in-production PORT=3001 DISCORD_WEBHOOK_URL= + +# Redis (required for rate limiting) +REDIS_URL=redis://localhost:6379 diff --git a/backend/package-lock.json b/backend/package-lock.json index a6382f7..1d880e0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "@stellar/stellar-sdk": "^12.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "ioredis": "^5.11.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", @@ -744,6 +745,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3819,6 +3826,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4087,6 +4103,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5481,6 +5506,28 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7533,6 +7580,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -8022,6 +8090,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 8e8cc9d..e3d4718 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "@stellar/stellar-sdk": "^12.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "ioredis": "^5.11.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5bd4149..e8bb750 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,8 +5,19 @@ import { WebhookModule } from './webhook/webhook.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { StellarModule } from './stellar/stellar.module'; import { SentryModule } from './sentry/sentry.module'; +import { RedisModule } from './common/redis/redis.module'; +import { RateLimitModule } from './common/rate-limit/rate-limit.module'; @Module({ - imports: [SentryModule, AuthModule, EscrowModule, WebhookModule, MonitoringModule, StellarModule], + imports: [ + SentryModule, + RedisModule, + RateLimitModule, + AuthModule, + EscrowModule, + WebhookModule, + MonitoringModule, + StellarModule, + ], }) export class AppModule {} diff --git a/backend/src/common/rate-limit/index.ts b/backend/src/common/rate-limit/index.ts new file mode 100644 index 0000000..3036725 --- /dev/null +++ b/backend/src/common/rate-limit/index.ts @@ -0,0 +1,3 @@ +export * from './rate-limit.decorator'; +export * from './rate-limit.guard'; +export * from './rate-limit.module'; diff --git a/backend/src/common/rate-limit/rate-limit.decorator.ts b/backend/src/common/rate-limit/rate-limit.decorator.ts new file mode 100644 index 0000000..1a27cb0 --- /dev/null +++ b/backend/src/common/rate-limit/rate-limit.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_RATE_LIMIT = 'skip_rate_limit'; +export const RATE_LIMIT_POINTS = 'rate_limit_points'; +export const RATE_LIMIT_DURATION = 'rate_limit_duration'; + +export const SkipRateLimit = () => SetMetadata(SKIP_RATE_LIMIT, true); + +export const RateLimit = (points: number, duration: number) => + SetMetadata(RATE_LIMIT_POINTS, points) && SetMetadata(RATE_LIMIT_DURATION, duration); diff --git a/backend/src/common/rate-limit/rate-limit.guard.spec.ts b/backend/src/common/rate-limit/rate-limit.guard.spec.ts new file mode 100644 index 0000000..cd4aad8 --- /dev/null +++ b/backend/src/common/rate-limit/rate-limit.guard.spec.ts @@ -0,0 +1,229 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { RateLimitGuard } from './rate-limit.guard'; +import { REDIS_CLIENT } from '../redis/redis.module'; +import { SKIP_RATE_LIMIT, RATE_LIMIT_POINTS, RATE_LIMIT_DURATION } from './rate-limit.decorator'; + +function mockContext(overrides?: { + skipRateLimit?: boolean; + points?: number; + duration?: number; + ip?: string; + url?: string; + routePath?: string; +}) { + const ip = overrides?.ip ?? '127.0.0.1'; + const url = overrides?.url ?? '/auth/challenge'; + const routePath = overrides?.routePath ?? '/auth/challenge'; + + const handler = () => {}; + const cls = class Mock {}; + + const context: any = { + getHandler: () => handler, + getClass: () => cls, + switchToHttp: () => ({ + getRequest: () => ({ + ip, + url, + route: { path: routePath }, + connection: { remoteAddress: '::1' }, + }), + }), + }; + + const metadata: Record = {}; + if (overrides?.skipRateLimit !== undefined) metadata[SKIP_RATE_LIMIT] = overrides.skipRateLimit; + if (overrides?.points !== undefined) metadata[RATE_LIMIT_POINTS] = overrides.points; + if (overrides?.duration !== undefined) metadata[RATE_LIMIT_DURATION] = overrides.duration; + + return { context, metadata }; +} + +describe('RateLimitGuard', () => { + let guard: RateLimitGuard; + let mockRedis: any; + + beforeEach(async () => { + mockRedis = { + incr: jest.fn(), + expire: jest.fn(), + ttl: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RateLimitGuard, + { + provide: REDIS_CLIENT, + useValue: mockRedis, + }, + { + provide: Reflector, + useValue: { + getAllAndOverride: jest.fn((_key: string) => { + return undefined; + }), + }, + }, + ], + }).compile(); + + guard = module.get(RateLimitGuard); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + describe('when Redis is not configured', () => { + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RateLimitGuard, + { provide: REDIS_CLIENT, useValue: null }, + { + provide: Reflector, + useValue: { getAllAndOverride: () => undefined }, + }, + ], + }).compile(); + + guard = module.get(RateLimitGuard); + }); + + it('should allow request when redis is null', async () => { + const { context } = mockContext(); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + }); + + describe('when @SkipRateLimit is present', () => { + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RateLimitGuard, + { provide: REDIS_CLIENT, useValue: mockRedis }, + { + provide: Reflector, + useValue: { + getAllAndOverride: (key: string) => { + if (key === SKIP_RATE_LIMIT) return true; + return undefined; + }, + }, + }, + ], + }).compile(); + + guard = module.get(RateLimitGuard); + }); + + it('should allow request without checking Redis', async () => { + const { context } = mockContext({ skipRateLimit: true }); + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(mockRedis.incr).not.toHaveBeenCalled(); + }); + }); + + describe('rate limit counting', () => { + beforeEach(async () => { + const reflector = { + getAllAndOverride: (key: string) => { + if (key === SKIP_RATE_LIMIT) return undefined; + if (key === RATE_LIMIT_POINTS) return undefined; + if (key === RATE_LIMIT_DURATION) return undefined; + return undefined; + }, + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RateLimitGuard, + { provide: REDIS_CLIENT, useValue: mockRedis }, + { provide: Reflector, useValue: reflector }, + ], + }).compile(); + + guard = module.get(RateLimitGuard); + }); + + it('should allow first request within limit', async () => { + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1 as any); + + const { context } = mockContext(); + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(mockRedis.incr).toHaveBeenCalledTimes(1); + expect(mockRedis.expire).toHaveBeenCalledTimes(1); + }); + + it('should allow request at exactly the limit', async () => { + mockRedis.incr.mockResolvedValue(100); + mockRedis.expire.mockResolvedValue(1 as any); + + const { context } = mockContext(); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + + it('should throw 429 when limit exceeded', async () => { + mockRedis.incr.mockResolvedValue(101); + mockRedis.expire.mockResolvedValue(1 as any); + mockRedis.ttl.mockResolvedValue(30 as any); + + const { context } = mockContext(); + await expect(guard.canActivate(context)).rejects.toThrow( + new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Too many requests — rate limit exceeded', + retryAfter: 30, + }, + HttpStatus.TOO_MANY_REQUESTS, + ), + ); + }); + + it('should request custom rate limit from reflector when set via decorator', async () => { + const customReflector = { + getAllAndOverride: (key: string) => { + if (key === SKIP_RATE_LIMIT) return undefined; + if (key === RATE_LIMIT_POINTS) return 10; + if (key === RATE_LIMIT_DURATION) return 10; + return undefined; + }, + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RateLimitGuard, + { provide: REDIS_CLIENT, useValue: mockRedis }, + { provide: Reflector, useValue: customReflector }, + ], + }).compile(); + + const customGuard = module.get(RateLimitGuard); + mockRedis.incr.mockResolvedValue(11); + mockRedis.ttl.mockResolvedValue(5 as any); + + const { context } = mockContext(); + await expect(customGuard.canActivate(context)).rejects.toThrow(HttpException); + }); + + it('should set expiry on first request', async () => { + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1 as any); + + const { context } = mockContext(); + await guard.canActivate(context); + expect(mockRedis.expire).toHaveBeenCalledWith(expect.any(String), 60); + }); + + it('should not set expiry on subsequent requests', async () => { + mockRedis.incr.mockResolvedValue(2); + + const { context } = mockContext(); + await guard.canActivate(context); + expect(mockRedis.expire).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/common/rate-limit/rate-limit.guard.ts b/backend/src/common/rate-limit/rate-limit.guard.ts new file mode 100644 index 0000000..9f9fd94 --- /dev/null +++ b/backend/src/common/rate-limit/rate-limit.guard.ts @@ -0,0 +1,79 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Inject, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Redis } from 'ioredis'; +import { REDIS_CLIENT } from '../redis/redis.module'; +import { SKIP_RATE_LIMIT, RATE_LIMIT_POINTS, RATE_LIMIT_DURATION } from './rate-limit.decorator'; + +const DEFAULT_POINTS = 100; +const DEFAULT_DURATION = 60; + +@Injectable() +export class RateLimitGuard implements CanActivate { + private readonly logger = new Logger(RateLimitGuard.name); + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis | null, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const skip = this.reflector.getAllAndOverride(SKIP_RATE_LIMIT, [ + context.getHandler(), + context.getClass(), + ]); + if (skip) return true; + + if (!this.redis) { + this.logger.warn('Redis not configured — rate limiting disabled'); + return true; + } + + const points = + this.reflector.getAllAndOverride(RATE_LIMIT_POINTS, [ + context.getHandler(), + context.getClass(), + ]) ?? DEFAULT_POINTS; + + const duration = + this.reflector.getAllAndOverride(RATE_LIMIT_DURATION, [ + context.getHandler(), + context.getClass(), + ]) ?? DEFAULT_DURATION; + + const request = context.switchToHttp().getRequest(); + const key = this.buildKey(request); + + const current = await this.redis.incr(key); + if (current === 1) { + await this.redis.expire(key, duration); + } + + if (current > points) { + const ttl = await this.redis.ttl(key); + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Too many requests — rate limit exceeded', + retryAfter: ttl, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + return true; + } + + private buildKey(request: any): string { + const ip = request.ip || request.connection?.remoteAddress || 'unknown'; + const route = request.route?.path || request.url || '/'; + return `ratelimit:${ip}:${route}`; + } +} diff --git a/backend/src/common/rate-limit/rate-limit.module.ts b/backend/src/common/rate-limit/rate-limit.module.ts new file mode 100644 index 0000000..7a1f891 --- /dev/null +++ b/backend/src/common/rate-limit/rate-limit.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { RateLimitGuard } from './rate-limit.guard'; +import { RedisModule } from '../redis/redis.module'; + +@Module({ + imports: [RedisModule], + providers: [ + { + provide: APP_GUARD, + useClass: RateLimitGuard, + }, + ], +}) +export class RateLimitModule {} diff --git a/backend/src/common/redis/index.ts b/backend/src/common/redis/index.ts new file mode 100644 index 0000000..2c88c87 --- /dev/null +++ b/backend/src/common/redis/index.ts @@ -0,0 +1 @@ +export * from './redis.module'; diff --git a/backend/src/common/redis/redis.module.ts b/backend/src/common/redis/redis.module.ts new file mode 100644 index 0000000..b7cd848 --- /dev/null +++ b/backend/src/common/redis/redis.module.ts @@ -0,0 +1,25 @@ +import { Module, Global } from '@nestjs/common'; +import { Redis } from 'ioredis'; + +export const REDIS_CLIENT = 'REDIS_CLIENT'; + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + useFactory: () => { + const url = process.env.REDIS_URL; + if (!url) return null; + const client = new Redis(url, { + maxRetriesPerRequest: 3, + retryStrategy: times => Math.min(times * 100, 3000), + lazyConnect: true, + }); + return client; + }, + }, + ], + exports: [REDIS_CLIENT], +}) +export class RedisModule {} diff --git a/backend/src/main.ts b/backend/src/main.ts index e745404..d5d6067 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -56,7 +56,12 @@ async function bootstrap() { 'The TrustFlow Backend API provides off-chain services for the TrustFlow gig economy platform. ' + 'It handles authentication, escrow management, webhook dispatch, and Stellar blockchain integration.\n\n' + '**Error Monitoring:** All 5xx errors and unhandled exceptions are automatically captured by Sentry ' + - 'for real-time alerting and triage. Set the `SENTRY_DSN` environment variable to enable.', + 'for real-time alerting and triage. Set the `SENTRY_DSN` environment variable to enable.\n\n' + + '**Rate Limiting:** All endpoints are rate-limited to **100 requests per minute** per IP address. ' + + 'When the limit is exceeded, the API returns a `429 Too Many Requests` response with a `retryAfter` field ' + + 'indicating the number of seconds to wait before retrying. Health check (`/health`) and metrics (`/metrics`) ' + + 'endpoints are exempt from rate limiting. ' + + 'Requires `REDIS_URL` environment variable to be configured.', ) .setVersion('1.0.0') .setContact('TrustFlow Protocol', 'https://trustflow.xyz', 'support@trustflow.xyz') diff --git a/backend/src/monitoring/health.controller.ts b/backend/src/monitoring/health.controller.ts index 9f539f6..c0b514f 100644 --- a/backend/src/monitoring/health.controller.ts +++ b/backend/src/monitoring/health.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiExcludeEndpoint } from '@nestjs/swagger'; import { HealthService } from './health.service'; import { MetricsService } from './metrics.service'; +import { SkipRateLimit } from '../common/rate-limit/rate-limit.decorator'; @ApiTags('Monitoring') @Controller() @@ -12,6 +13,7 @@ export class HealthController { ) {} @Get('health') + @SkipRateLimit() @ApiOperation({ summary: 'Health check', description: 'Returns the health status of the API. Used for liveness and readiness probes.', @@ -34,6 +36,7 @@ export class HealthController { } @Get('metrics') + @SkipRateLimit() @ApiOperation({ summary: 'Prometheus metrics', description: 'Returns metrics in Prometheus format for monitoring and alerting.',