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);
});
});
@@ -39,12 +39,13 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
const LOGGABLE_PREFIX_LENGTH = 12;
/**
* Failed key attempts per client IP before that IP is refused with 429 for
* the rest of the window - the same budget as the shop's ERP export. A key
* holds 256 random bits, so this is not what keeps it safe; it keeps a
* misconfigured or hostile client from hammering the key lookup, and the
* block applies to every key from that address, so a correct key cannot be
* told apart from a wrong one while it lasts.
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys for the rest of the window - the same
* budget as the shop's ERP export. It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
@@ -52,7 +53,7 @@ export const API_KEY_FAILURE_LIMIT = {
maxTrackedSources: 10_000,
} as const;
type RejectReason = 'malformed' | 'unknown' | 'revoked' | 'expired';
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */
export interface ApiKeyPrincipal {
@@ -199,19 +200,40 @@ export class ApiKeysService {
/**
* Resolves a presented key to what it may do, or null for anything that
* must not pass: malformed, unknown, revoked or expired. Each of those
* counts as a failed attempt of the client IP; an IP over the limit gets
* 429 before any lookup.
* must not pass: malformed, unknown, revoked or expired.
*
* The key is found by the SHA-256 of the presented token through a unique
* index - never by comparing secrets byte by byte, so response timing says
* nothing about the stored key.
* nothing about the stored key. Order matters: the lookup comes first, and
* only an invalid key counts against (or is stopped by) the failure limit
* of the client network. A malformed token is not a guess at a key and is
* refused without lookup and without counting.
*/
async authenticate(
token: string,
clientIp?: string,
): Promise<ApiKeyPrincipal | null> {
const source = clientIp ?? 'unknown';
if (!TOKEN_PATTERN.test(token)) {
return null;
}
const apiKey = await this.prisma.apiKey.findUnique({
where: { keyHash: hashToken(token) },
});
const now = new Date();
const reason = rejectReason(apiKey, now);
if (reason === null && apiKey) {
return this.accept(apiKey, clientIp, now);
}
this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): void {
// 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';
@@ -228,46 +250,22 @@ export class ApiKeysService {
HttpStatus.TOO_MANY_REQUESTS,
);
}
const result = await this.resolve(token, clientIp);
if ('principal' in result) {
return result.principal;
}
// Only the public prefix is ever logged, never the key itself.
const prefix =
result.reason === 'malformed'
? '(malformed)'
: token.slice(0, LOGGABLE_PREFIX_LENGTH);
this.logger.warn(
`API key rejected (${result.reason}): ${prefix} from ${source}`,
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (this.failures.recordFailure(subject)) {
this.logger.warn(
`API key attempts from ${subject} blocked for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
);
}
return null;
}
private async resolve(
token: string,
private async accept(
apiKey: ApiKey,
clientIp: string | undefined,
): Promise<{ principal: ApiKeyPrincipal } | { reason: RejectReason }> {
if (!TOKEN_PATTERN.test(token)) {
return { reason: 'malformed' };
}
const apiKey = await this.prisma.apiKey.findUnique({
where: { keyHash: hashToken(token) },
});
const now = new Date();
if (!apiKey) {
return { reason: 'unknown' };
}
if (apiKey.revokedAt) {
return { reason: 'revoked' };
}
if (apiKey.expiresAt && apiKey.expiresAt <= now) {
return { reason: 'expired' };
}
now: Date,
): Promise<ApiKeyPrincipal> {
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
// The key itself is right: say so, so the admin finds the cause - a
// thief learns nothing new, they already hold the key.
@@ -290,12 +288,17 @@ export class ApiKeysService {
}
// Intersect with today's allowlist: narrowing it later also narrows
// keys issued before.
return {
principal: { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) },
};
return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) };
}
}
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
if (!apiKey) return 'unknown';
if (apiKey.revokedAt) return 'revoked';
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
return null;
}
/** The key from an `Authorization: Bearer ack_…` header, or null. */
export function extractApiKeyToken(
authorization: string | undefined,
@@ -74,4 +74,39 @@ describe('FailedAttemptLimiter', () => {
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
});
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 2,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('angreifer');
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
small.recordFailure('neu');
expect(small.trackedSources()).toBe(2);
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
expect(small.isBlocked('angreifer')).toBe(true);
// Verdrängt wurde der nicht gesperrte Eintrag.
expect(small.recordFailure('harmlos')).toBe(false);
});
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
expect(small.isBlocked('a')).toBe(false);
expect(small.isBlocked('b')).toBe(true);
expect(small.isBlocked('c')).toBe(true);
});
});
@@ -69,13 +69,26 @@ export class FailedAttemptLimiter {
this.windows.delete(source);
}
}
// Immer noch voll: den ältesten Eintrag verdrängen (Map behält die
// Einfügereihenfolge). Lieber eine Zählung verlieren als unbegrenzt wachsen.
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
// die Einfügereihenfolge).
if (this.windows.size >= this.options.maxTrackedSources) {
for (const oldest of this.windows.keys()) {
this.windows.delete(oldest);
break;
}
const victim =
this.oldestWhere(
(window) => window.failures < this.options.maxFailures,
) ?? this.oldestWhere(() => true);
if (victim !== undefined) this.windows.delete(victim);
}
}
private oldestWhere(
matches: (window: Window) => boolean,
): string | undefined {
for (const [source, window] of this.windows) {
if (matches(window)) return source;
}
return undefined;
}
}
+53
View File
@@ -65,3 +65,56 @@ describe('configureApp trust proxy logging', () => {
log.mockRestore();
});
});
describe('configureApp trust proxy warning', () => {
const originalProxy = process.env.TRUST_PROXY;
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = originalProxy;
if (originalEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalEnv;
});
function configure(): jest.SpyInstance {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
return warn;
}
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
const calls = warn.mock.calls as unknown[][];
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
}
afterEach(() => jest.restoreAllMocks());
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
process.env.NODE_ENV = 'production';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(1);
});
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
process.env.NODE_ENV = 'production';
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
it('warnt nicht in der Entwicklung', () => {
process.env.NODE_ENV = 'development';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
});
+11 -3
View File
@@ -50,9 +50,17 @@ export function configureApp(app: NestExpressApplication): void {
// to work correctly rather than rate-limiting all traffic together.
const trustProxy = trustProxySetting();
app.set('trust proxy', trustProxy);
new Logger('Bootstrap').log(
describeTrustProxy(trustProxy, !!process.env.TRUST_PROXY?.trim()),
);
const bootstrapLogger = new Logger('Bootstrap');
const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
// proxy, so rate limits, API-key failure limits and IP allowlists act
// on a shared address instead of the client.
bootstrapLogger.warn(
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
);
}
// No ETags: they let caches and proxies revalidate (and keep) personal
// responses that are meant to be no-store.
app.set('etag', false);
@@ -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. */