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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 11:18:56 +02:00
1 parent d7f0d2c13b
commit 886b4e75e1
5 files changed
+146 -2

No files matched your search

+3 -2
View File
@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; 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 { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@@ -112,7 +113,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
providers: [ providers: [
// Throttle first: a request with a wrong token or API key must count // 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. // 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: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
@@ -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<string, any>): Promise<string>;
};
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');
});
});
@@ -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<string, any>): Promise<string> {
const ip: unknown = req.ip;
return Promise.resolve(
typeof ip === 'string' && ip ? rateLimitSubject(ip) : 'unknown',
);
}
}
@@ -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('');
});
});
@@ -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<string>(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`;
}