fix(api): throttle before auth, configurable proxy trust, no-store and a strict CSP

- ThrottlerGuard runs first, so requests with wrong tokens or API keys
  count against the rate limit before any database lookup.
- TRUST_PROXY (hop count or Express list) replaces the hard-coded 1.
- Every API answer except the public lot pages is Cache-Control: no-store
  without ETag; helmet sends default-src 'none' / frame-ancestors 'none'
  (Swagger keeps the page policy when enabled) and same-site CORP.
- Request log lines carry the path only - no query strings (the SSO
  callback's code and state no longer reach the logs).
- In production the server refuses to start without an https
  FRONTEND_ORIGIN or with COOKIE_SECURE=false.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:37:31 +02:00
1 parent 2de93bc087
commit 9ae1d96a32
7 files changed
+134 -4

No files matched your search

+42 -2
View File
@@ -4,18 +4,58 @@ 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';
/**
* How many reverse-proxy hops to trust for X-Forwarded-For. It decides the
* client IP behind every rate limit and API-key IP allowlist: too few and
* all users share the proxy's IP, too many and a client can forge its own.
* A hop count ("1", the single K8s ingress) or Express' list syntax
* ("loopback, 10.0.0.0/8").
*/
export function trustProxySetting(): number | string {
const raw = process.env.TRUST_PROXY?.trim();
if (!raw) {
return 1;
}
return /^\d+$/.test(raw) ? Number(raw) : raw;
}
export function configureApp(app: NestExpressApplication): void {
// Trust the single reverse-proxy hop (K8s ingress) 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.
app.set('trust proxy', 1);
app.set('trust proxy', trustProxySetting());
// 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());
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());
+3 -1
View File
@@ -106,9 +106,11 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
],
controllers: [],
providers: [
// Throttle first: a request with a wrong token or API key must count
// against the limit too, before any database lookup is spent on it.
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
IdempotencyCleanupService,
],
@@ -0,0 +1,22 @@
import type { NextFunction, Request, Response } from 'express';
// The public origin pages of honey lots are the only answers meant for
// everyone; they may be cached briefly.
const CACHEABLE_PREFIX = '/api/v1/public/';
/**
* Personal data, tokens and exports must not end up in a browser's
* back/forward cache or a shared proxy. Every API answer is no-store unless
* it is one of the public, anonymous ones.
*/
export function noStoreMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!req.path.startsWith(CACHEABLE_PREFIX)) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
}
next();
}
@@ -18,7 +18,9 @@ export function requestLoggerMiddleware(
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const line = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
// Path only: query strings can carry secrets (the SSO callback's
// authorization code and state, search terms with personal data).
const line = `${req.method} ${req.baseUrl}${req.path} ${res.statusCode} ${duration}ms`;
if (res.statusCode >= 500) {
logger.error(line);
} else if (res.statusCode >= 400) {
+16
View File
@@ -15,6 +15,21 @@ const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
// generate one.
const MIN_JWT_SECRET_LENGTH = 32;
function assertProductionSettings(): void {
if (process.env.NODE_ENV !== 'production') {
return;
}
// Without it CORS and SSO redirects silently fall back to localhost.
if (!process.env.FRONTEND_ORIGIN?.startsWith('https://')) {
throw new Error(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
}
if (process.env.COOKIE_SECURE === 'false') {
throw new Error('COOKIE_SECURE=false is for local HTTP development only');
}
}
function assertRequiredEnvVars(): void {
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
@@ -32,6 +47,7 @@ function assertRequiredEnvVars(): void {
async function bootstrap() {
assertRequiredEnvVars();
assertProductionSettings();
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks();