From e28cc8e0f3796ff8ea64a405b7ea35a9d1ea6667 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 11:47:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(api-keys):=20Fehlversuche=20je=20Client-Net?= =?UTF-8?q?z=20z=C3=A4hlen=20(IPv6=20/64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ARCHITECTURE.md | 2 +- .../src/api-keys/api-keys.service.spec.ts | 15 +++++++++++++++ ApiCore-Backend/src/api-keys/api-keys.service.ts | 10 +++++++--- docs/erp-inventar-export.md | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d79a906..12197e8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -143,7 +143,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un | Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt | | Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) | | Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten | -| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 abgelehnten Schlüsseln in 15 min je IP `429`; Logs nur mit Schlüssel-Präfix | +| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 abgelehnten Schlüsseln in 15 min je IP (IPv6: je /64) `429`; Logs nur mit Schlüssel-Präfix | | Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body | | Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies | | Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` | diff --git a/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts b/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts index 4eda736..d24d77e 100644 --- a/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts +++ b/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts @@ -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', () => { diff --git a/ApiCore-Backend/src/api-keys/api-keys.service.ts b/ApiCore-Backend/src/api-keys/api-keys.service.ts index 8336681..cbde071 100644 --- a/ApiCore-Backend/src/api-keys/api-keys.service.ts +++ b/ApiCore-Backend/src/api-keys/api-keys.service.ts @@ -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 { 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; diff --git a/docs/erp-inventar-export.md b/docs/erp-inventar-export.md index d887df0..9cce421 100644 --- a/docs/erp-inventar-export.md +++ b/docs/erp-inventar-export.md @@ -16,7 +16,7 @@ Der ERPNext-Importer wird genau nach diesem Dokument gebaut. Änderungen am Vert | Speicherung | ApiCore speichert nur den SHA-256-Hash. Gesucht wird über den Hash (eindeutiger Index), nie durch Vergleich des Geheimnisses. Der Schlüssel selbst wird genau einmal beim Anlegen angezeigt. | | Abschalten | Jederzeit widerrufbar (wirkt sofort), optional mit Ablaufdatum. | | Absender | Optional nur von bestimmten IP-Adressen/Netzen (Allowlist am Schlüssel). | -| Fehlversuche | Nach 20 abgelehnten Schlüsseln (fehlerhaft, unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten antwortet ApiCore dieser IP-Adresse mit `429`, auch für richtige Schlüssel. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. | +| Fehlversuche | Nach 20 abgelehnten Schlüsseln (fehlerhaft, unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten antwortet ApiCore dieser IP-Adresse (bei IPv6 dem ganzen /64-Netz) mit `429`, auch für richtige Schlüssel. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. | | Protokoll | Abgelehnte Schlüssel werden mit Grund, Präfix (`ack_abcd1234`) und IP protokolliert, nie mit dem Schlüssel. Query-Strings landen nicht im Log. | | Trennung | Eigener Schlüssel nur für ERPNext. Der Wabenhain-Schlüssel (`shop-sync:*`) kommt an diese Endpunkte nicht heran und umgekehrt. |