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:
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user