From 886b4e75e1635bbb1e909528142f20c59d2ca31f Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 11:18:56 +0200 Subject: [PATCH] fix: Rate-Limit pro IPv6-/64 statt pro Einzeladresse Der Standard-ThrottlerGuard trackt die volle req.ip. Ein IPv6-Client kann innerhalb seines /64 beliebig Adressen rotieren und so die Login-/Refresh-/SSO-Limits umgehen. Neuer ClientIpThrottlerGuard liefert IPv4 unveraendert, IPv4-mapped als IPv4 und IPv6 als /64-Praefix. Co-Authored-By: Claude Opus 5.5 (1M context) --- ApiCore-Backend/src/app.module.ts | 5 +- .../client-ip-throttler.guard.spec.ts | 26 +++++++++ .../throttler/client-ip-throttler.guard.ts | 14 +++++ .../throttler/rate-limit-subject.spec.ts | 56 +++++++++++++++++++ .../common/throttler/rate-limit-subject.ts | 47 ++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 ApiCore-Backend/src/common/throttler/client-ip-throttler.guard.spec.ts create mode 100644 ApiCore-Backend/src/common/throttler/client-ip-throttler.guard.ts create mode 100644 ApiCore-Backend/src/common/throttler/rate-limit-subject.spec.ts create mode 100644 ApiCore-Backend/src/common/throttler/rate-limit-subject.ts diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index c38337b..7dd32b7 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'; @@ -112,7 +113,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`; +}