fix(api-keys): gültige Schlüssel nie durch die Fehlversuchs-Sperre abweisen

Befunde aus dem Sicherheitsreview des Branches:

- MEDIUM: Die Sperre lief vor der Schlüsselsuche und zählte auch falsch
  aufgebaute Tokens. Wer die gemeinsame Adresse (Cluster-Ausgang, Proxy)
  nutzt oder per X-Forwarded-For vortäuscht, hätte ERPNext und den Shop
  15 Minuten aussperren können. Jetzt: erst Suche über den Hash; ein
  gültiger, nicht widerrufener, nicht abgelaufener Schlüssel kommt immer
  durch; 429 nur für unbekannte, widerrufene und abgelaufene Schlüssel;
  falsch aufgebaute Tokens sofort 401 ohne Zählung. Beim Start warnt
  ApiCore, wenn TRUST_PROXY in Produktion fehlt (im Cluster gesetzt).
- LOW: Bei vollem Speicher verdrängt der Limiter zuerst nicht gesperrte
  Einträge, gesperrte erst zuletzt.
- LOW: Bewegungs-Cursor mit Jahr außerhalb 2000-2100 ergab 500, jetzt 400.
- LOW/INFO: Vertrag legt fest, dass ERPNext Produkte und Chargen bei jedem
  Lauf vollständig neu liest (Voll-Spiegel) und Bewegungen inkrementell;
  remainingQuantity ist live, Bewegungen kommen 5 Minuten verzögert.

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 12:00:03 +02:00
1 parent e28cc8e0f3
commit fe7b61c3c1
11 files changed
+300 -81

No files matched your search

+43 -4
View File
@@ -577,6 +577,22 @@ describe('ERP-Inventar-Export (e2e)', () => {
await get(`bewegungen?nach=${productCursor}`, erpKey).expect(400);
});
it('lehnt einen Bewegungs-Cursor mit unmöglichem Jahr ab (400 statt 500)', async () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: '+275760-09-13T00:00:00.000Z',
id: movementIds.hofladen,
});
await get(`bewegungen?nach=${cursor}`, erpKey).expect(400);
});
it('verträgt ein fernes seit (leere Seite statt Fehler)', async () => {
const body = (
await get('bewegungen?seit=9999-12-31T00:00:00Z', erpKey).expect(200)
).body as PageBody<Item>;
expect(body.items).toEqual([]);
});
it('lehnt seit und nach zusammen ab (400)', async () => {
const cursor = encodeCursor({
k: 'bewegungen',
@@ -645,7 +661,7 @@ describe('ERP-Inventar-Export (e2e)', () => {
});
describe('Fehlversuche', () => {
it('sperrt eine Adresse nach zu vielen falschen Schlüsseln mit 429, andere nicht', async () => {
it('sperrt nach zu vielen unbekannten Schlüsseln weitere ungültige mit 429, andere Adressen nicht', async () => {
const attacker = '203.0.113.50';
const wrong = `ack_abcd1234_${'Y'.repeat(43)}`;
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
@@ -656,9 +672,32 @@ describe('ERP-Inventar-Export (e2e)', () => {
statusCode: 429,
message: expect.stringContaining('Fehlversuche') as string,
});
// Auch ein richtiger Schlüssel verrät von dort nichts mehr.
await get('produkte', erpKey, attacker).expect(429);
await get('produkte', erpKey, '203.0.113.51').expect(200);
await get('produkte', wrong, '203.0.113.51').expect(401);
});
it('lässt einen gültigen Schlüssel auch von einer gesperrten Adresse durch', async () => {
// Sonst könnte jeder, der die gemeinsame Adresse (Cluster-Ausgang,
// Proxy) nutzt oder fälscht, ERPNext oder den Shop aussperren.
const shared = '203.0.113.60';
const wrong = `ack_abcd1234_${'X'.repeat(43)}`;
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await get('produkte', wrong, shared).expect(401);
}
await get('produkte', wrong, shared).expect(429);
await get('produkte', erpKey, shared).expect(200);
await request(server())
.get('/api/v1/shop-sync/stock')
.set('Authorization', `Bearer ${shopKey}`)
.set('X-Forwarded-For', shared)
.expect(200);
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht', async () => {
const ip = '203.0.113.70';
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures + 5; i++) {
await get('produkte', 'ack_x', ip).expect(401);
}
await get('produkte', `ack_abcd1234_${'W'.repeat(43)}`, ip).expect(401);
});
});
});