feat(security): share rate limits across instances, drain syncs on shutdown

- Rate limiting counters live in PostgreSQL (throttler_records), shared by
  every instance, one atomic upsert per request; fails open.
- On shutdown, new syncs are refused (503) and the running one is waited for
  up to SYNC_SHUTDOWN_TIMEOUT; Prisma disconnects last.
- Hiboutik calls never follow redirects and are capped at 50 MB.
- Prisma errors no longer print query arguments (errorFormat minimal).
- In production, DATABASE_URL must require TLS.
- The sync lock connection times out after 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MastersGame
2026-11-08 12:21:15 +01:00
parent 35a7c27cb7
commit 3dd5369175
14 changed files with 414 additions and 34 deletions

View File

@@ -1,20 +1,25 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Injectable, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from './generated/client';
import { PrismaPg } from '@prisma/adapter-pg';
/** Prisma client shared by the whole app (global PrismaModule), connected to `DATABASE_URL` through the pg driver adapter. */
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
export class PrismaService extends PrismaClient implements OnModuleInit, OnApplicationShutdown {
constructor() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
super({ adapter });
// 'minimal': by default, error messages print the query arguments (emails, names, addresses...), which end up in the logs
super({ adapter, errorFormat: 'minimal' });
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
/**
* Last shutdown hook (after `onModuleDestroy` and `beforeApplicationShutdown`, once the HTTP server is closed):
* a synchronization still running is waited for in `beforeApplicationShutdown` and keeps the database until then.
*/
async onApplicationShutdown() {
await this.$disconnect();
}
}

View File

@@ -130,7 +130,7 @@ model Customer {
updatedAt DateTime @updatedAt
dateOfBirth DateTime?
/// Kept when removed in Hiboutik (an empty Hiboutik email does not erase it)
/// Null when the customer has no email in Hiboutik (erased when removed there)
email String?
firstName String
hiboutikId Int @unique
@@ -227,3 +227,19 @@ model OrderLine {
@@map("order_lines")
}
/// Rate limiting counter shared by every instance (ThrottlerDatabaseStorage): one fixed window per throttler key
/// (route + client IP). Not synced from Hiboutik. Rows whose window and block are over are purged regularly.
model ThrottlerRecord {
/// Key generated by the ThrottlerGuard (hash of route, throttler name and client IP)
key String @id
/// Requests counted in the current window
hits Int
/// End of the current window
expiresAt DateTime @db.Timestamptz(3)
/// End of the block once the limit was exceeded, null when not blocked
blockedUntil DateTime? @db.Timestamptz(3)
@@index([expiresAt])
@@map("throttler_records")
}