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); diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index cd19ec1..4fc1cfd 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; -import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard'; import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; @@ -114,7 +115,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea 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: ClientIpThrottlerGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, diff --git a/ApiCore-Backend/src/common/throttler/client-ip-throttler.guard.spec.ts b/ApiCore-Backend/src/common/throttler/client-ip-throttler.guard.spec.ts new file mode 100644 index 0000000..34ce441 --- /dev/null +++ b/ApiCore-Backend/src/common/throttler/client-ip-throttler.guard.spec.ts @@ -0,0 +1,26 @@ +import { ClientIpThrottlerGuard } from './client-ip-throttler.guard'; + +describe('ClientIpThrottlerGuard.getTracker', () => { + // getTracker is protected and needs none of the guard's dependencies. + const guard = Object.create( + ClientIpThrottlerGuard.prototype, + ) as ClientIpThrottlerGuard & { + getTracker(req: Record): Promise; + }; + + it('tracks IPv6 clients by their /64 prefix', async () => { + await expect(guard.getTracker({ ip: '2001:db8:1:2::9' })).resolves.toBe( + '2001:db8:1:2::/64', + ); + }); + + it('tracks IPv4 clients by address', async () => { + await expect(guard.getTracker({ ip: '203.0.113.7' })).resolves.toBe( + '203.0.113.7', + ); + }); + + it('falls back to a stable key when req.ip is missing', async () => { + await expect(guard.getTracker({})).resolves.toBe('unknown'); + }); +}); diff --git a/ApiCore-Backend/src/common/throttler/client-ip-throttler.guard.ts b/ApiCore-Backend/src/common/throttler/client-ip-throttler.guard.ts new file mode 100644 index 0000000..a0c20f6 --- /dev/null +++ b/ApiCore-Backend/src/common/throttler/client-ip-throttler.guard.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import { rateLimitSubject } from './rate-limit-subject'; + +/** ThrottlerGuard that counts per client network (IPv6 /64), not per address. */ +@Injectable() +export class ClientIpThrottlerGuard extends ThrottlerGuard { + protected getTracker(req: Record): Promise { + const ip: unknown = req.ip; + return Promise.resolve( + typeof ip === 'string' && ip ? rateLimitSubject(ip) : 'unknown', + ); + } +} diff --git a/ApiCore-Backend/src/common/throttler/rate-limit-subject.spec.ts b/ApiCore-Backend/src/common/throttler/rate-limit-subject.spec.ts new file mode 100644 index 0000000..e63f830 --- /dev/null +++ b/ApiCore-Backend/src/common/throttler/rate-limit-subject.spec.ts @@ -0,0 +1,56 @@ +import { rateLimitSubject } from './rate-limit-subject'; + +describe('rateLimitSubject', () => { + it('returns IPv4 unchanged', () => { + expect(rateLimitSubject('203.0.113.7')).toBe('203.0.113.7'); + }); + + it('unwraps IPv4-mapped IPv6 addresses', () => { + expect(rateLimitSubject('::ffff:203.0.113.7')).toBe('203.0.113.7'); + expect(rateLimitSubject('::FFFF:203.0.113.7')).toBe('203.0.113.7'); + }); + + it('reduces a compressed IPv6 address to its /64 prefix', () => { + expect(rateLimitSubject('2001:db8:1:2::5')).toBe('2001:db8:1:2::/64'); + }); + + it('reduces a full-form IPv6 address to its /64 prefix', () => { + expect(rateLimitSubject('2001:0db8:0001:0002:aaaa:bbbb:cccc:dddd')).toBe( + '2001:db8:1:2::/64', + ); + }); + + it('normalises uppercase', () => { + expect(rateLimitSubject('2001:DB8:1:2::ABCD')).toBe('2001:db8:1:2::/64'); + }); + + it('handles :: at the start and for the unspecified address', () => { + expect(rateLimitSubject('::1')).toBe('0:0:0:0::/64'); + expect(rateLimitSubject('::')).toBe('0:0:0:0::/64'); + }); + + it('handles :: that swallows part of the prefix', () => { + expect(rateLimitSubject('2001:db8::1')).toBe('2001:db8:0:0::/64'); + }); + + it('maps different interface identifiers in one /64 to the same key', () => { + expect(rateLimitSubject('2001:db8:1:2:1::1')).toBe( + rateLimitSubject('2001:db8:1:2:ffff:eeee:dddd:cccc'), + ); + }); + + it('maps different /64 networks to different keys', () => { + expect(rateLimitSubject('2001:db8:1:2::1')).not.toBe( + rateLimitSubject('2001:db8:1:3::1'), + ); + }); + + it('ignores an IPv6 zone id', () => { + expect(rateLimitSubject('fe80::1%eth0')).toBe('fe80:0:0:0::/64'); + }); + + it('returns unparseable input unchanged', () => { + expect(rateLimitSubject('not-an-ip')).toBe('not-an-ip'); + expect(rateLimitSubject('')).toBe(''); + }); +}); diff --git a/ApiCore-Backend/src/common/throttler/rate-limit-subject.ts b/ApiCore-Backend/src/common/throttler/rate-limit-subject.ts new file mode 100644 index 0000000..3e5e569 --- /dev/null +++ b/ApiCore-Backend/src/common/throttler/rate-limit-subject.ts @@ -0,0 +1,47 @@ +import { isIPv4, isIPv6 } from 'node:net'; + +const MAPPED_V4_PREFIX = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i; +const PREFIX_GROUPS = 4; // /64 = first four 16-bit groups + +/** Expands an IPv6 address (no zone id) into its eight 16-bit groups. */ +function expandIPv6(address: string): number[] { + let text = address; + // Embedded IPv4 tail (e.g. 64:ff9b::1.2.3.4) becomes two hex groups. + const tail = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text); + if (tail) { + const [a, b, c, d] = tail[1].split('.').map(Number); + const hi = ((a << 8) | b).toString(16); + const lo = ((c << 8) | d).toString(16); + text = `${text.slice(0, tail.index)}${hi}:${lo}`; + } + const [head, rest] = text.split('::'); + const headGroups = head ? head.split(':') : []; + const restGroups = rest === undefined ? [] : rest ? rest.split(':') : []; + const missing = 8 - headGroups.length - restGroups.length; + const groups = + rest === undefined + ? headGroups + : [...headGroups, ...Array(missing).fill('0'), ...restGroups]; + return groups.map((g) => parseInt(g, 16)); +} + +/** + * Rate-limit key for a client IP. IPv4 stays as is, IPv4-mapped IPv6 becomes + * plain IPv4, and IPv6 collapses to its /64 prefix: a single subscriber + * controls a whole /64, so keying on the full address would let it rotate + * addresses to dodge every limit. Unparseable input is returned unchanged. + */ +export function rateLimitSubject(ip: string): string { + const mapped = MAPPED_V4_PREFIX.exec(ip); + if (mapped && isIPv4(mapped[1])) { + return mapped[1]; + } + const address = ip.split('%')[0]; + if (!isIPv6(address)) { + return ip; + } + const prefix = expandIPv6(address) + .slice(0, PREFIX_GROUPS) + .map((g) => g.toString(16)); + return `${prefix.join(':')}::/64`; +} diff --git a/deploy/k8s/backend/ingress.yaml b/deploy/k8s/backend/ingress.yaml index ca1e39d..72f331f 100644 --- a/deploy/k8s/backend/ingress.yaml +++ b/deploy/k8s/backend/ingress.yaml @@ -6,8 +6,13 @@ metadata: # Requires cert-manager with a ClusterIssuer named "letsencrypt-prod" # (or swap for whatever issuer/cert mechanism the cluster already uses). cert-manager.io/cluster-issuer: letsencrypt-prod + # Nur Traffic ueber Cloudflare zulassen. Die Middleware liegt im Repo + # rke2-cluster (security/cloudflare-only-middlewares.yaml). Sie schuetzt vor + # Direktzugriff am Origin vorbei, nicht vor fremden Cloudflare-Zonen. + traefik.ingress.kubernetes.io/router.middlewares: apicore-cloudflare-only@kubernetescrd spec: - ingressClassName: nginx + # Der Cluster (RKE2) nutzt Traefik; nur damit wirkt die Middleware-Annotation oben. + ingressClassName: traefik tls: - hosts: - api.apicore.example.com diff --git a/deploy/k8s/frontend/ingress.yaml b/deploy/k8s/frontend/ingress.yaml index 08a91b9..aef77a3 100644 --- a/deploy/k8s/frontend/ingress.yaml +++ b/deploy/k8s/frontend/ingress.yaml @@ -6,8 +6,13 @@ metadata: # Requires cert-manager with a ClusterIssuer named "letsencrypt-prod" # (or swap for whatever issuer/cert mechanism the cluster already uses). cert-manager.io/cluster-issuer: letsencrypt-prod + # Nur Traffic ueber Cloudflare zulassen. Die Middleware liegt im Repo + # rke2-cluster (security/cloudflare-only-middlewares.yaml). Sie schuetzt vor + # Direktzugriff am Origin vorbei, nicht vor fremden Cloudflare-Zonen. + traefik.ingress.kubernetes.io/router.middlewares: apicore-cloudflare-only@kubernetescrd spec: - ingressClassName: nginx + # Der Cluster (RKE2) nutzt Traefik; nur damit wirkt die Middleware-Annotation oben. + ingressClassName: traefik tls: - hosts: - apicore.example.com