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

@@ -64,7 +64,30 @@ describe('ERP-Cursor', () => {
'bewegungen',
raw({ k: 'bewegungen', ts: '2026-10-01', id: 'c1' }),
],
[
'Jahr jenseits des Datenbankbereichs',
'bewegungen',
raw({ k: 'bewegungen', ts: '+275760-09-13T00:00:00.000Z', id: 'c1' }),
],
[
'Jahr vor 2000',
'bewegungen',
raw({ k: 'bewegungen', ts: '1999-12-31T23:59:59.999Z', id: 'c1' }),
],
[
'Jahr nach 2100',
'bewegungen',
raw({ k: 'bewegungen', ts: '2101-01-01T00:00:00.000Z', id: 'c1' }),
],
] as const)('lehnt ab: %s', (_label, kind, cursor) => {
expect(() => decodeCursor(cursor, kind)).toThrow(BadRequestException);
});
it.each(['2000-01-01T00:00:00.000Z', '2100-12-31T23:59:59.999Z'])(
'nimmt den Rand des erlaubten Bereichs an: %s',
(ts) => {
const cursor = encodeCursor({ k: 'bewegungen', ts, id: 'c1' });
expect(decodeCursor(cursor, 'bewegungen').ts).toBe(ts);
},
);
});
+10 -1
View File
@@ -23,6 +23,11 @@ type CursorOf<K extends ErpCursorKind> = Extract<ErpCursor, { k: K }>;
// etwas anderes bedeuten könnten.
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
// Zeitstempel eines Bewegungs-Cursors: nur plausible Jahre. JavaScript
// kennt Jahre bis +275760, die Datenbankabfrage nicht - das wäre ein 500.
const MIN_CURSOR_YEAR = 2000;
const MAX_CURSOR_YEAR = 2100;
const FIELDS: Record<ErpCursorKind, readonly string[]> = {
produkte: ['id', 'k'],
chargen: ['id', 'k', 't'],
@@ -55,7 +60,11 @@ function parse(raw: string): Record<string, unknown> {
function isCanonicalTimestamp(value: unknown): value is string {
if (typeof value !== 'string') return false;
const date = new Date(value);
return !Number.isNaN(date.getTime()) && date.toISOString() === value;
if (Number.isNaN(date.getTime()) || date.toISOString() !== value) {
return false;
}
const year = date.getUTCFullYear();
return year >= MIN_CURSOR_YEAR && year <= MAX_CURSOR_YEAR;
}
/** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */