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

@@ -135,44 +135,78 @@ describe('ApiKeysService.authenticate', () => {
).rejects.toBeInstanceOf(ForbiddenException);
});
it(`sperrt eine Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429, ohne die Datenbank zu fragen`, async () => {
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
async function failFrom(ip: string, times: number): Promise<void> {
for (let i = 0; i < times; i++) {
// Unbekannter, aber richtig aufgebauter Schlüssel.
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
}
prisma.apiKey.findUnique.mockClear();
prisma.apiKey.findUnique.mockResolvedValue(storedKey());
}
// Gesperrt ist gesperrt - auch ein richtiger Schlüssel verrät nichts mehr.
const blocked = service.authenticate(TOKEN, '203.0.113.7');
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
await expect(blocked).rejects.toBeInstanceOf(HttpException);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
expect(loggedText()).toContain('203.0.113.7');
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
expect(loggedText()).toContain('203.0.113.7');
// Andere Adressen sind nicht betroffen.
await expect(service.authenticate(TOKEN, '198.51.100.1')).resolves.toEqual({
await expect(
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
).resolves.toBe(null);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
id: 'key-1',
scopes: ['erp-inventory:read'],
});
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
null,
);
}
// Weder gesperrt noch eine Datenbankabfrage.
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).resolves.toBe(null);
});
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 service.authenticate(
OTHER_TOKEN,
`2001:db8:1:2::${i.toString(16)}`,
);
}
await expect(
service.authenticate(TOKEN, '2001:db8:1:2::ffff'),
service.authenticate(OTHER_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' });
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
).resolves.toBe(null);
});
});