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:
1 parent
d7f0d2c13b
commit
886b4e75e1
5 files changed
+146
-2
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in new issue
Block a user