Add DB-backed rate limiting with guard and decorator

RateLimitService implements fixed-window counters as one atomic
PostgreSQL upsert (race-safe under concurrency, proven by test), with
opportunistic sweeping of expired windows and an explicit reset for
successful-login scenarios. The global RateLimitGuard applies
@RateLimit({scope, limit, windowSeconds}) per client IP and answers
429 with Retry-After; main.ts trusts the single Caddy hop so req.ip
is the real client.

Closes #11

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-05 00:43:54 +02:00
parent 36608177f6
commit 31f23c12a2
6 changed files with 201 additions and 1 deletions

View File

@ -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],

View File

@ -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<void> {
// 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<NestExpressApplication>(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();

View File

@ -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<boolean> {
const options = this.reflector.get<RateLimitOptions | undefined>(
RATE_LIMIT_KEY,
context.getHandler(),
);
if (!options) return true;
const request = context.switchToHttp().getRequest<Request>();
// 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<Response>()
.setHeader('Retry-After', String(result.retryAfterSeconds));
throw new HttpException('Too many requests', HttpStatus.TOO_MANY_REQUESTS);
}
}

View File

@ -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 {}

View File

@ -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();
});
});

View File

@ -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<RateLimitResult> {
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<number> {
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<void> {
await this.prisma.rateLimit.deleteMany({ where: { key: `${scope}:${key}` } });
}
}