Files
ApiCore/ApiCore-Backend/src/app.config.ts
T
Nick SzaunigandClaude Opus 5.5 53fab102f2 Merge main checkpoint c01a388 into feat/erp-inventar-export
Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 22:49:53 +02:00

119 lines
4.5 KiB
TypeScript

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, <Cloudflare IPv4+IPv6 ranges>", 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,
}),
);
}