diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 620fc8f..7121da6 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,12 +7,14 @@ import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; import { HealthModule } from './health/health.module'; import { PrismaModule } from './prisma/prisma.module'; +import { RateLimitModule } from './rate-limit/rate-limit.module'; import { UsersModule } from './users/users.module'; @Module({ imports: [ ConfigModule, PrismaModule, + RateLimitModule, UsersModule, LoggerModule.forRootAsync({ inject: [AppConfig], diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 527439d..b0052a8 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,7 @@ import { execFileSync } from 'node:child_process'; import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import { apiEnvSchema, parseEnv } from '@dorfteich/shared'; import { Logger } from 'nestjs-pino'; @@ -27,8 +28,11 @@ async function bootstrap(): Promise { // bufferLogs holds early log lines until the pino logger is attached, // so even bootstrap errors come out as structured JSON. - const app = await NestFactory.create(AppModule, { bufferLogs: true }); + const app = await NestFactory.create(AppModule, { bufferLogs: true }); app.useLogger(app.get(Logger)); + // One reverse-proxy hop (Caddy) in front of us: req.ip must reflect the + // real client for rate limiting and audit logs. + app.set('trust proxy', 1); app.setGlobalPrefix('api/v1'); app.enableShutdownHooks(); diff --git a/apps/api/src/rate-limit/rate-limit.guard.ts b/apps/api/src/rate-limit/rate-limit.guard.ts new file mode 100644 index 0000000..ce79e99 --- /dev/null +++ b/apps/api/src/rate-limit/rate-limit.guard.ts @@ -0,0 +1,62 @@ +import { + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Injectable, + SetMetadata, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request, Response } from 'express'; + +import { RateLimitService } from './rate-limit.service'; + +export interface RateLimitOptions { + scope: string; + limit: number; + windowSeconds: number; +} + +const RATE_LIMIT_KEY = 'rateLimit'; + +/** + * Per-client-IP rate limit for a route, e.g. + * `@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })`. + * Account-scoped limits (login backoff) are applied inside services where + * the account is known. + */ +export const RateLimit = (options: RateLimitOptions): MethodDecorator => + SetMetadata(RATE_LIMIT_KEY, options); + +@Injectable() +export class RateLimitGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly rateLimits: RateLimitService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const options = this.reflector.get( + RATE_LIMIT_KEY, + context.getHandler(), + ); + if (!options) return true; + + const request = context.switchToHttp().getRequest(); + // req.ip honors X-Forwarded-For because main.ts sets `trust proxy`. + const ip = request.ip ?? 'unknown'; + const result = await this.rateLimits.hit( + options.scope, + `ip:${ip}`, + options.limit, + options.windowSeconds, + ); + if (result.allowed) return true; + + context + .switchToHttp() + .getResponse() + .setHeader('Retry-After', String(result.retryAfterSeconds)); + throw new HttpException('Too many requests', HttpStatus.TOO_MANY_REQUESTS); + } +} diff --git a/apps/api/src/rate-limit/rate-limit.module.ts b/apps/api/src/rate-limit/rate-limit.module.ts new file mode 100644 index 0000000..f90e813 --- /dev/null +++ b/apps/api/src/rate-limit/rate-limit.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; + +import { RateLimitGuard } from './rate-limit.guard'; +import { RateLimitService } from './rate-limit.service'; + +/** + * Global: the guard runs for every route but only acts where a handler + * carries @RateLimit metadata; the service is injectable everywhere for + * account-scoped limits. + */ +@Global() +@Module({ + providers: [RateLimitService, { provide: APP_GUARD, useClass: RateLimitGuard }], + exports: [RateLimitService], +}) +export class RateLimitModule {} diff --git a/apps/api/src/rate-limit/rate-limit.service.db.test.ts b/apps/api/src/rate-limit/rate-limit.service.db.test.ts new file mode 100644 index 0000000..97210af --- /dev/null +++ b/apps/api/src/rate-limit/rate-limit.service.db.test.ts @@ -0,0 +1,52 @@ +import { afterAll, describe, expect, it } from 'vitest'; + +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { PrismaService } from '../prisma/prisma.service'; +import { RateLimitService } from './rate-limit.service'; + +describe.skipIf(!hasTestDb)('RateLimitService (database)', () => { + const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!; + const service = hasTestDb ? new RateLimitService(prisma) : null!; + const scope = `test-${uniqueSuffix()}`; + + afterAll(async () => { + if (!hasTestDb) return; + await prisma.rateLimit.deleteMany({ where: { key: { startsWith: scope } } }); + await prisma.$disconnect(); + }); + + it('allows up to the limit and blocks beyond it', async () => { + for (let i = 0; i < 3; i += 1) { + expect((await service.hit(scope, 'ip:1', 3, 60)).allowed).toBe(true); + } + const blocked = await service.hit(scope, 'ip:1', 3, 60); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('counts concurrent hits without losing any', async () => { + const results = await Promise.all( + Array.from({ length: 20 }, () => service.hit(scope, 'ip:2', 10, 60)), + ); + expect(results.filter((r) => r.allowed)).toHaveLength(10); + }); + + it('rolls the window over after it expires', async () => { + expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(true); + expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 1100)); + expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(true); + }); + + it('sweeps counters from long-ended windows', async () => { + await prisma.rateLimit.create({ + data: { + key: `${scope}:ip:old`, + windowStart: new Date(Date.now() - 48 * 60 * 60 * 1000), + count: 5, + }, + }); + await service.cleanupExpired(); + expect(await prisma.rateLimit.findUnique({ where: { key: `${scope}:ip:old` } })).toBeNull(); + }); +}); diff --git a/apps/api/src/rate-limit/rate-limit.service.ts b/apps/api/src/rate-limit/rate-limit.service.ts new file mode 100644 index 0000000..90d4a94 --- /dev/null +++ b/apps/api/src/rate-limit/rate-limit.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +export interface RateLimitResult { + allowed: boolean; + retryAfterSeconds: number; +} + +/** + * Fixed-window rate limiting backed by PostgreSQL (ADR 0002: no Redis). + * One atomic upsert per hit keeps concurrent requests race-safe; expired + * rows are swept opportunistically on writes. + */ +@Injectable() +export class RateLimitService { + constructor(private readonly prisma: PrismaService) {} + + async hit( + scope: string, + key: string, + limit: number, + windowSeconds: number, + ): Promise { + const rowKey = `${scope}:${key}`; + const rows = await this.prisma.$queryRaw<{ count: number; window_start: Date }[]>` + INSERT INTO rate_limits (key, window_start, count) + VALUES (${rowKey}, now(), 1) + ON CONFLICT (key) DO UPDATE SET + count = CASE + WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds}) + THEN 1 ELSE rate_limits.count + 1 END, + window_start = CASE + WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds}) + THEN now() ELSE rate_limits.window_start END + RETURNING count, window_start + `; + const row = rows[0]; + if (!row) return { allowed: true, retryAfterSeconds: 0 }; + + const elapsedMs = Date.now() - row.window_start.getTime(); + const retryAfterSeconds = Math.max(1, Math.ceil(windowSeconds - elapsedMs / 1000)); + + // Opportunistic sweep (~1% of hits) so the table stays small without + // needing a scheduler this early. + if (Math.random() < 0.01) void this.cleanupExpired().catch(() => undefined); + + return { allowed: row.count <= limit, retryAfterSeconds }; + } + + /** Deletes counters whose window ended more than a day ago. */ + async cleanupExpired(): Promise { + const result = await this.prisma.rateLimit.deleteMany({ + where: { windowStart: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } }, + }); + return result.count; + } + + /** Clears one counter — used when an action should reset it (e.g. successful login). */ + async reset(scope: string, key: string): Promise { + await this.prisma.rateLimit.deleteMany({ where: { key: `${scope}:${key}` } }); + } +}