fix(api-keys): Fehlversuche je Client-Netz zählen (IPv6 /64)
Wie der Throttler seit 886b4e7: die Sperre nach abgelehnten Schlüsseln
zählt über rateLimitSubject, damit wechselnde Adressen im eigenen
IPv6-/64 das Budget nicht zurücksetzen. Im Log steht weiter die Adresse.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
e357e8be9a
commit
e28cc8e0f3
4 files changed
+24
-5
No files matched your search
@@ -159,6 +159,21 @@ describe('ApiKeysService.authenticate', () => {
|
||||
scopes: ['erp-inventory:read'],
|
||||
});
|
||||
});
|
||||
|
||||
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
|
||||
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
|
||||
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||
await service.authenticate(TOKEN, `2001:db8:1:2::${i.toString(16)}`);
|
||||
}
|
||||
await expect(
|
||||
service.authenticate(TOKEN, '2001:db8:1:2::ffff'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
// Ein anderes /64 ist frei.
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||
await expect(
|
||||
service.authenticate(TOKEN, '2001:db8:1:3::1'),
|
||||
).resolves.toMatchObject({ id: 'key-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractApiKeyToken', () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||
|
||||
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||
return [
|
||||
@@ -211,7 +212,10 @@ export class ApiKeysService {
|
||||
clientIp?: string,
|
||||
): Promise<ApiKeyPrincipal | null> {
|
||||
const source = clientIp ?? 'unknown';
|
||||
if (this.failures.isBlocked(source)) {
|
||||
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||
// addresses inside one's own prefix does not reset the budget.
|
||||
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
||||
if (this.failures.isBlocked(subject)) {
|
||||
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
||||
// bare string), so clients can read `message` uniformly.
|
||||
throw new HttpException(
|
||||
@@ -236,9 +240,9 @@ export class ApiKeysService {
|
||||
this.logger.warn(
|
||||
`API key rejected (${result.reason}): ${prefix} from ${source}`,
|
||||
);
|
||||
if (this.failures.recordFailure(source)) {
|
||||
if (this.failures.recordFailure(subject)) {
|
||||
this.logger.warn(
|
||||
`API key attempts from ${source} blocked for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
|
||||
`API key attempts from ${subject} blocked for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user