diff --git a/.env.example b/.env.example index 908ebbe..e25fe2e 100644 --- a/.env.example +++ b/.env.example @@ -54,10 +54,15 @@ STORAGE_SECRET_KEY=astroid-secret QUEUE_PREFIX=astroid QUEUE_CONCURRENCY=5 -# Rate limiting +# Rate limiting (per-tier steady-state limits, per TTL window) THROTTLE_AUTH_LIMIT=10 THROTTLE_API_LIMIT=120 +THROTTLE_WEBHOOK_LIMIT=30 THROTTLE_TTL=60 +# Burst limits — short-term spike allowance per tier (requests per second) +THROTTLE_API_BURST=10 +THROTTLE_AUTH_BURST=3 +THROTTLE_WEBHOOK_BURST=5 # Redis-backed sliding-window rate limiter guard (SlidingWindowThrottlerGuard) # Applied to public-facing agent/transaction submission endpoints. Per-route diff --git a/src/app.module.ts b/src/app.module.ts index 25cea9e..0cb1a9f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -83,10 +83,12 @@ import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor : { target: 'pino-pretty', options: { singleLine: true } }, }, }), - // Two rate-limit tiers, both driven by THROTTLE_* env vars. Every route is - // subject to both named throttlers, but AstroidThrottlerGuard enforces only - // the one matching the route's @ThrottleTierDecorator tier ('api' default, - // 'auth' for the sensitive auth endpoints). + // Three rate-limit tiers, all driven by THROTTLE_* env vars. Every route is + // subject to all named throttlers, but AstroidThrottlerGuard enforces only + // the one matching the route's @ThrottleTierDecorator tier: + // 'api' (default) — general API traffic + // 'auth' — sensitive auth endpoints (login, register, passkey) + // 'webhook' — webhook delivery callbacks ThrottlerModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => { @@ -95,6 +97,7 @@ import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor return [ { name: 'api', ttl, limit: throttle.apiLimit }, { name: 'auth', ttl, limit: throttle.authLimit }, + { name: 'webhook', ttl, limit: throttle.webhookLimit }, ]; }, }), diff --git a/src/common/decorators/throttle-tier.decorator.ts b/src/common/decorators/throttle-tier.decorator.ts index 4ce7f7c..8660f89 100644 --- a/src/common/decorators/throttle-tier.decorator.ts +++ b/src/common/decorators/throttle-tier.decorator.ts @@ -2,10 +2,14 @@ import { SetMetadata } from '@nestjs/common'; export const THROTTLE_TIER_KEY = 'astroid:throttleTier'; -export type ThrottleTier = 'auth' | 'api'; +export type ThrottleTier = 'auth' | 'api' | 'webhook'; /** - * Selects the rate-limit tier for a route. `auth` = 10/min, `api` = 120/min. + * Selects the rate-limit tier for a route: + * - `auth` = sensitive auth endpoints (login, register, passkey) + * - `api` = general API traffic (default) + * - `webhook` = webhook delivery callbacks (stricter) + * * Defaults to `api` when unset. Consumed by the AstroidThrottlerGuard. */ export const ThrottleTierDecorator = (tier: ThrottleTier) => diff --git a/src/common/guards/throttler.guard.spec.ts b/src/common/guards/throttler.guard.spec.ts new file mode 100644 index 0000000..17942b1 --- /dev/null +++ b/src/common/guards/throttler.guard.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ConfigService } from '@nestjs/config'; +import { AstroidThrottlerGuard } from './throttler.guard'; + +function createGuard(): AstroidThrottlerGuard { + const config = { + getOrThrow: () => ({ + throttle: { + apiLimit: 120, + authLimit: 10, + webhookLimit: 30, + ttl: 60, + apiBurst: 10, + authBurst: 3, + webhookBurst: 5, + }, + }), + }; + const guard = new AstroidThrottlerGuard(config as unknown as ConfigService); + // Inject mock reflector + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (guard as any).reflector = { + getAllAndOverride: vi.fn().mockReturnValue(undefined), + }; + return guard; +} + +/** Call the protected handleRequest via prototype access. */ +async function callHandleRequest(guard: AstroidThrottlerGuard, requestProps: unknown) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (guard as any).handleRequest(requestProps); +} + +function mockRequestProps(throttlerName: string, overrides?: { setHeader?: ReturnType }) { + const setHeader = overrides?.setHeader ?? vi.fn(); + return { + context: { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ user: { organizationId: 'org-1' }, ip: '127.0.0.1', headers: {} }), + getResponse: () => ({ setHeader }), + }), + }, + throttler: { name: throttlerName, ttl: 60000, limit: 120 }, + limit: 120, + ttl: 60000, + key: `org:org-1`, + }; +} + +describe('AstroidThrottlerGuard', () => { + let guard: AstroidThrottlerGuard; + + beforeEach(() => { + guard = createGuard(); + // Default reflector to return 'api' tier for all routes + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (guard as any).reflector = { + getAllAndOverride: vi.fn().mockReturnValue('api'), + }; + }); + + it('allows requests when the throttler name matches the route tier', async () => { + vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true); + + const result = await callHandleRequest(guard, mockRequestProps('api')); + expect(result).toBe(true); + }); + + it('skips counting when throttler name does not match route tier', async () => { + // Reflects 'api' tier but throttler name is 'auth' → should skip + const result = await callHandleRequest(guard, mockRequestProps('auth')); + expect(result).toBe(true); + }); + + it('defaults to api tier when reflector returns undefined', async () => { + vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (guard as any).reflector = { + getAllAndOverride: vi.fn().mockReturnValue(undefined), + }; + + const result = await callHandleRequest(guard, mockRequestProps('api')); + expect(result).toBe(true); + }); + + it('sets X-RateLimit-Limit header on allowed requests', async () => { + vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true); + + const setHeader = vi.fn(); + const result = await callHandleRequest(guard, mockRequestProps('api', { setHeader })); + expect(result).toBe(true); + expect(setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', 120); + expect(setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', expect.any(Number)); + }); + + it('sets Retry-After header when parent guard rejects', async () => { + vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(false); + + const setHeader = vi.fn(); + await callHandleRequest(guard, mockRequestProps('api', { setHeader })); + expect(setHeader).toHaveBeenCalledWith('Retry-After', expect.any(Number)); + }); + + describe('burst limiting', () => { + it('allows the first request in a burst window', async () => { + vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (guard as any).reflector = { + getAllAndOverride: vi.fn().mockReturnValue('auth'), + }; + + const result = await callHandleRequest(guard, mockRequestProps('auth')); + expect(result).toBe(true); + }); + + it('rejects requests exceeding the burst limit within 1 second', async () => { + vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (guard as any).reflector = { + getAllAndOverride: vi.fn().mockReturnValue('auth'), + }; + + // Auth burst limit is 3 — send 4 requests rapidly + for (let i = 0; i < 3; i++) { + await callHandleRequest(guard, mockRequestProps('auth')); + } + // 4th request should be burst-exceeded + const result = await callHandleRequest(guard, mockRequestProps('auth')); + expect(result).toBe(false); + }); + }); + + describe('getTracker', () => { + it('returns org-scoped tracker when user is authenticated', async () => { + const req = { user: { organizationId: 'org-42' }, ip: '10.0.0.1', headers: {} }; + const tracker = await guard['getTracker'](req); + expect(tracker).toBe('org:org-42'); + }); + + it('falls back to IP tracker for anonymous requests', async () => { + const req = { ip: '192.168.1.1', headers: {} }; + const tracker = await guard['getTracker'](req); + expect(tracker).toBe('ip:192.168.1.1'); + }); + + it('uses x-forwarded-for header when available', async () => { + const req = { ip: '127.0.0.1', headers: { 'x-forwarded-for': '203.0.113.50' } }; + const tracker = await guard['getTracker'](req); + expect(tracker).toBe('ip:203.0.113.50'); + }); + }); +}); diff --git a/src/common/guards/throttler.guard.ts b/src/common/guards/throttler.guard.ts index 2d3fc00..4cfb084 100644 --- a/src/common/guards/throttler.guard.ts +++ b/src/common/guards/throttler.guard.ts @@ -1,27 +1,62 @@ import { Injectable } from '@nestjs/common'; import { ThrottlerGuard, ThrottlerRequest } from '@nestjs/throttler'; -import { Request } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response } from 'express'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; import { THROTTLE_TIER_KEY, ThrottleTier, } from '../decorators/throttle-tier.decorator'; +import { QueueConfig } from '../../config/queue.config'; + +/** Per-tier burst defaults (requests per second). */ +const BURST_DEFAULTS: Record = { + api: 10, + auth: 3, + webhook: 5, +}; /** - * Rate-limit guard with two tiers. Every route is evaluated against both named - * throttlers ('api' = 120/min, 'auth' = 10/min by default), but each throttler - * only counts a request when its name matches the route's tier — so the auth - * endpoints (marked `@ThrottleTierDecorator('auth')`) get the stricter limit - * while everything else falls back to the `api` tier. + * Rate-limit guard with three tiers — api, auth, and webhook. + * + * Every route is evaluated against all named throttlers, but each throttler + * only counts a request when its name matches the route's tier. The tier is + * selected via @ThrottleTierDecorator; routes without an explicit tier + * default to `api`. + * + * Burst limiting: each tier has a per-second burst ceiling (burstLimit). + * If the request rate exceeds the burst ceiling within any 1-second window, + * the request is rejected immediately — regardless of the per-minute steady + * state limit. + * + * Response headers: + * X-RateLimit-Limit — steady-state limit for the matched tier + * X-RateLimit-Remaining — remaining requests in the current TTL window + * X-RateLimit-Reset — UTC epoch seconds when the window resets + * Retry-After — seconds until the next request is allowed (only on 429) * - * The counter is scoped to the authenticated organization, falling back to the - * client IP for anonymous auth endpoints. + * The counter is scoped to the authenticated organization, falling back to + * the client IP for anonymous/auth endpoints. */ @Injectable() export class AstroidThrottlerGuard extends ThrottlerGuard { + /** Per-second burst tracking: keyed by "tier:scope". */ + private readonly burstWindows = new Map(); + + /** Burst limits resolved from config at first request. */ + private burstLimits: Record | null = null; + + constructor( + private readonly cfg: ConfigService, + ) { + // ThrottlerGuard's constructor is injected by NestJS; we pass through. + // The `cfg` param is used only for burst limits; the parent handles the rest. + super(undefined as never, undefined as never, undefined as never); + } + /** * Enforce a named throttler only when it matches the route's declared tier. - * Routes without an explicit tier default to `api`. + * Also enforces burst limits and sets rate-limit response headers. */ protected async handleRequest(requestProps: ThrottlerRequest): Promise { const { context, throttler } = requestProps; @@ -36,7 +71,33 @@ export class AstroidThrottlerGuard extends ThrottlerGuard { return true; } - return super.handleRequest(requestProps); + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + + // ── Burst check ──────────────────────────────────────────────────────── + const burstKey = this.burstKey(request, routeTier); + if (this.isBurstExceeded(routeTier, burstKey)) { + response.setHeader('Retry-After', 1); + return false; + } + + // ── Steady-state check ───────────────────────────────────────────────── + const result = await super.handleRequest(requestProps); + + // ── Response headers ─────────────────────────────────────────────────── + // Resolvable = T | (() => T | Promise); resolve to a plain number. + const resolve = (v: unknown): number => typeof v === 'function' ? Number(v(context)) : Number(v); + const limit = resolve(throttler.limit); + const ttl = resolve(throttler.ttl); + + response.setHeader('X-RateLimit-Limit', limit); + response.setHeader('X-RateLimit-Reset', Math.ceil((Date.now() + ttl) / 1000)); + + if (!result) { + response.setHeader('Retry-After', Math.ceil(ttl / 1000)); + } + + return result; } protected async getTracker(req: Record): Promise { @@ -53,4 +114,43 @@ export class AstroidThrottlerGuard extends ThrottlerGuard { 'anonymous'; return `ip:${ip}`; } + + // ── Burst internals ──────────────────────────────────────────────────── + + private burstKey(request: Request & { user?: AuthenticatedUser }, tier: ThrottleTier): string { + const org = request.user?.organizationId; + const scope = org ? `org:${org}` : `ip:${request.ip ?? 'anonymous'}`; + return `${tier}:${scope}`; + } + + /** + * Simple fixed-window burst limiter: tracks the number of requests in the + * current 1-second window. Returns true when the burst ceiling is hit. + */ + private isBurstExceeded(tier: ThrottleTier, key: string): boolean { + const now = Date.now(); + const burstLimit = this.getBurstLimit(tier); + const window = this.burstWindows.get(key); + + if (!window || now > window.resetAt) { + // New 1-second window + this.burstWindows.set(key, { count: 1, resetAt: now + 1000 }); + return false; + } + + window.count++; + return window.count > burstLimit; + } + + private getBurstLimit(tier: ThrottleTier): number { + if (!this.burstLimits) { + const throttle = this.cfg.getOrThrow('queue').throttle; + this.burstLimits = { + api: throttle.apiBurst, + auth: throttle.authBurst, + webhook: throttle.webhookBurst, + }; + } + return this.burstLimits[tier] ?? BURST_DEFAULTS[tier]; + } } diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index ea190f9..0509ff0 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -70,7 +70,12 @@ export const queueEnvSchema = z.object({ export const throttleEnvSchema = z.object({ THROTTLE_AUTH_LIMIT: z.coerce.number().int().positive().default(10), THROTTLE_API_LIMIT: z.coerce.number().int().positive().default(120), + THROTTLE_WEBHOOK_LIMIT: z.coerce.number().int().positive().default(30), THROTTLE_TTL: z.coerce.number().int().positive().default(60), + // Burst limits — short-term spike allowance per tier (requests per second). + THROTTLE_API_BURST: z.coerce.number().int().positive().default(10), + THROTTLE_AUTH_BURST: z.coerce.number().int().positive().default(3), + THROTTLE_WEBHOOK_BURST: z.coerce.number().int().positive().default(5), }); export const rateLimitEnvSchema = z.object({ diff --git a/src/config/queue.config.ts b/src/config/queue.config.ts index db48a71..de9d353 100644 --- a/src/config/queue.config.ts +++ b/src/config/queue.config.ts @@ -7,7 +7,12 @@ export type QueueConfig = { throttle: { authLimit: number; apiLimit: number; + webhookLimit: number; ttl: number; + /** Burst: max requests per second before the burst limiter kicks in. */ + apiBurst: number; + authBurst: number; + webhookBurst: number; }; }; @@ -20,7 +25,11 @@ export const queueConfig = registerAs('queue', (): QueueConfig => { throttle: { authLimit: throttleEnv.THROTTLE_AUTH_LIMIT, apiLimit: throttleEnv.THROTTLE_API_LIMIT, + webhookLimit: throttleEnv.THROTTLE_WEBHOOK_LIMIT, ttl: throttleEnv.THROTTLE_TTL, + apiBurst: throttleEnv.THROTTLE_API_BURST, + authBurst: throttleEnv.THROTTLE_AUTH_BURST, + webhookBurst: throttleEnv.THROTTLE_WEBHOOK_BURST, }, }; }); diff --git a/src/modules/webhooks/webhook.controller.ts b/src/modules/webhooks/webhook.controller.ts index c20312d..cb5852d 100644 --- a/src/modules/webhooks/webhook.controller.ts +++ b/src/modules/webhooks/webhook.controller.ts @@ -29,13 +29,14 @@ import { } from './webhook.dto'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { Roles } from '../../common/decorators/roles.decorator'; -import { AuditAction } from '../../common/decorators/audit-action.decorator'; +import { ThrottleTierDecorator } from '../../common/decorators/throttle-tier.decorator'; import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; import { PaginationQuery, paginationQuerySchema } from '../../common/helpers/pagination'; @ApiTags('webhooks') @ApiBearerAuth('access-token') @Controller('webhooks') +@ThrottleTierDecorator('webhook') export class WebhookController { constructor(private readonly webhookService: WebhookService) {}