From d9670dcda5aab2afac1a23e79404ca9f874e7fde Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 11:23:41 +0200 Subject: [PATCH] fix: TRUST_PROXY-Kommentar aktualisieren und Modus beim Start loggen Die Kommentare beschrieben noch einen einzelnen Reverse-Proxy-Hop. Sie beschreiben jetzt die Cloudflare/Traefik-Liste. Beim Start wird einmal auf info-Level geloggt, ob Hop-Zahl, Liste oder der Default aktiv ist (nur Anzahl, keine Adressen), damit eine fehlende Produktionseinstellung auffaellt. Das Standardverhalten bleibt unveraendert. Co-Authored-By: Claude Opus 5.5 (1M context) --- ApiCore-Backend/src/app.config.spec.ts | 67 ++++++++++++++++++++++++++ ApiCore-Backend/src/app.config.ts | 43 ++++++++++++++--- 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 ApiCore-Backend/src/app.config.spec.ts diff --git a/ApiCore-Backend/src/app.config.spec.ts b/ApiCore-Backend/src/app.config.spec.ts new file mode 100644 index 0000000..2a81592 --- /dev/null +++ b/ApiCore-Backend/src/app.config.spec.ts @@ -0,0 +1,67 @@ +import { Logger } from '@nestjs/common'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { + configureApp, + describeTrustProxy, + trustProxySetting, +} from './app.config'; + +describe('trustProxySetting', () => { + const original = process.env.TRUST_PROXY; + afterEach(() => { + if (original === undefined) delete process.env.TRUST_PROXY; + else process.env.TRUST_PROXY = original; + }); + + it('defaults to one hop when unset or blank', () => { + delete process.env.TRUST_PROXY; + expect(trustProxySetting()).toBe(1); + process.env.TRUST_PROXY = ' '; + expect(trustProxySetting()).toBe(1); + }); + + it('parses a number as hop count and anything else as a list', () => { + process.env.TRUST_PROXY = '2'; + expect(trustProxySetting()).toBe(2); + process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16'; + expect(trustProxySetting()).toBe('loopback, 10.42.0.0/16'); + }); +}); + +describe('describeTrustProxy', () => { + it('reports the hop count mode and the default fallback', () => { + expect(describeTrustProxy(1, false)).toBe( + 'TRUST_PROXY not set, using default of 1 trusted hop', + ); + expect(describeTrustProxy(2, true)).toBe('TRUST_PROXY: 2 trusted hop(s)'); + }); + + it('reports the list mode with entry count but not the entries', () => { + const text = describeTrustProxy( + 'loopback, 10.42.0.0/16, 2400:cb00::/32', + true, + ); + expect(text).toBe('TRUST_PROXY: list of 3 trusted proxy range(s)'); + }); +}); + +describe('configureApp trust proxy logging', () => { + it('logs the active mode once at startup', () => { + const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(); + const app = { + set: jest.fn(), + useBodyParser: jest.fn(), + use: jest.fn(), + useGlobalFilters: jest.fn(), + enableCors: jest.fn(), + setGlobalPrefix: jest.fn(), + enableVersioning: jest.fn(), + useGlobalPipes: jest.fn(), + }; + configureApp(app as unknown as NestExpressApplication); + expect( + log.mock.calls.filter(([m]) => String(m).startsWith('TRUST_PROXY')), + ).toHaveLength(1); + log.mockRestore(); + }); +}); diff --git a/ApiCore-Backend/src/app.config.ts b/ApiCore-Backend/src/app.config.ts index 6a9b67c..f04f2b8 100644 --- a/ApiCore-Backend/src/app.config.ts +++ b/ApiCore-Backend/src/app.config.ts @@ -1,4 +1,9 @@ -import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + Logger, + RequestMethod, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import { NestExpressApplication } from '@nestjs/platform-express'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; @@ -7,11 +12,15 @@ import { requestLoggerMiddleware } from './common/middleware/request-logger.midd 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"). + * 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/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(); @@ -21,11 +30,29 @@ export function trustProxySetting(): number | string { 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 single reverse-proxy hop (K8s ingress) so req.ip reflects the + // 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. - app.set('trust proxy', trustProxySetting()); + const trustProxy = trustProxySetting(); + app.set('trust proxy', trustProxy); + new Logger('Bootstrap').log( + describeTrustProxy(trustProxy, !!process.env.TRUST_PROXY?.trim()), + ); // No ETags: they let caches and proxies revalidate (and keep) personal // responses that are meant to be no-store. app.set('etag', false);