import { Logger, RequestMethod, ValidationPipe, VersioningType, } from '@nestjs/common'; import { NestExpressApplication } from '@nestjs/platform-express'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware'; import { noStoreMiddleware } from './common/middleware/no-store.middleware'; /** * Which proxies to trust for X-Forwarded-For. It decides the client IP behind * every rate limit and API-key IP allowlist: trust too little and all users * share the proxy's IP, trust too much and a client can forge its own. * * Production runs client -> Cloudflare -> Traefik -> backend, so the hop * count is not stable; TRUST_PROXY is an Express list there ("loopback, * 10.42.0.0/16, ", see * deploy/k8s/base/backend/configmap.example.yaml). A plain number ("1") is a hop * count and stays the default when the variable is unset. */ export function trustProxySetting(): number | string { const raw = process.env.TRUST_PROXY?.trim(); if (!raw) { return 1; } return /^\d+$/.test(raw) ? Number(raw) : raw; } /** Startup log line for the active mode; never includes the addresses. */ export function describeTrustProxy( setting: number | string, isExplicit: boolean, ): string { if (typeof setting === 'number') { return isExplicit ? `TRUST_PROXY: ${setting} trusted hop(s)` : `TRUST_PROXY not set, using default of ${setting} trusted hop`; } const entries = setting.split(',').filter((e) => e.trim()).length; return `TRUST_PROXY: list of ${entries} trusted proxy range(s)`; } export function configureApp(app: NestExpressApplication): void { // Trust the configured proxies (Traefik, Cloudflare) so req.ip reflects the // real client IP from X-Forwarded-For — required for per-client throttling // to work correctly rather than rate-limiting all traffic together. const trustProxy = trustProxySetting(); app.set('trust proxy', trustProxy); const bootstrapLogger = new Logger('Bootstrap'); const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim(); bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit)); if (!trustProxyExplicit && process.env.NODE_ENV === 'production') { // Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the // proxy, so rate limits, API-key failure limits and IP allowlists act // on a shared address instead of the client. bootstrapLogger.warn( 'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml', ); } // No ETags: they let caches and proxies revalidate (and keep) personal // responses that are meant to be no-store. app.set('etag', false); // Default 100kb JSON body limit is too small for the avatar upload, which // sends the image as a base64 data: URL in the JSON body (not multipart - // see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers // the 2MB decoded size cap plus base64 overhead. app.useBodyParser('json', { limit: '5mb' }); app.use( helmet({ // This server only answers JSON and files. A strict policy means even // a response that is somehow rendered as a page can load nothing and // cannot be framed. The Swagger UI (opt-in) needs scripts, so it keeps // helmet's default page policy. contentSecurityPolicy: process.env.ENABLE_SWAGGER === 'true' ? undefined : { useDefaults: false, directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"], baseUri: ["'none'"], formAction: ["'none'"], }, }, crossOriginResourcePolicy: { policy: 'same-site' }, }), ); app.use(noStoreMiddleware); app.use(cookieParser()); app.use(requestLoggerMiddleware); app.useGlobalFilters(new AllExceptionsFilter()); app.enableCors({ origin: process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000', credentials: true, }); app.setGlobalPrefix('api', { exclude: [ { path: 'health', method: RequestMethod.GET }, { path: 'health/ready', method: RequestMethod.GET }, ], }); app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); }