import { beforeEach, describe, expect, it, vi } from 'vitest' /** * Die Anmeldesperre - und warum sie sich frueher selbst am Leben hielt. * * Bis v1.10.2 wurde jeder Aufruf festgehalten, auch der, der wegen einer * laufenden Sperre gar nicht geprueft wurde. Gleichzeitig lief die Frist ab * dem juengsten Fehlversuch. Beides zusammen ergab eine Rueckkopplung: Wer * regelmaessig gegen eine gesperrte Adresse klopfte, verlaengerte die Sperre * bei jedem Klopfen um die volle Dauer - und sperrte den Inhaber damit * dauerhaft aus, ohne dessen Passwort zu kennen. Nebenbei wuchs die Tabelle * mit jedem Versuch. * * Diese Tests laufen gegen eine Attrappe der Datenbank. Das reicht: Was hier * geprueft wird, ist die Rechnung, nicht die Abfrage. */ // Kuerzere Sperre als Zeitfenster - nur so wird sichtbar, ob die Frist // wirklich ablaeuft oder bis zum Fensterende stehen bleibt. process.env.LOGIN_MAX_VERSUCHE = '5' process.env.LOGIN_FENSTER_SEKUNDEN = '3600' process.env.LOGIN_SPERRE_SEKUNDEN = '900' type Zeile = { id: string email: string ipHash: string | null erfolg: boolean grund: string | null zeitpunkt: Date } let tabelle: Zeile[] = [] let laufendeNummer = 0 /** Nur die Abfrageformen, die in ratelimit.ts wirklich vorkommen. */ function passt(zeile: Zeile, wo: Record): boolean { if ('erfolg' in wo && zeile.erfolg !== wo.erfolg) return false if ('email' in wo && zeile.email !== wo.email) return false if ('grund' in wo) { const bedingung = wo.grund if (typeof bedingung === 'object' && bedingung !== null && 'not' in bedingung) { if (zeile.grund === (bedingung as { not: string }).not) return false } else if (zeile.grund !== bedingung) { return false } } if ('zeitpunkt' in wo) { const { gte, lt } = wo.zeitpunkt as { gte?: Date; lt?: Date } if (gte && zeile.zeitpunkt < gte) return false if (lt && zeile.zeitpunkt >= lt) return false } if ('OR' in wo) { const zweige = wo.OR as Record[] if (!zweige.some((zweig) => passt(zeile, zweig))) return false } if ('ipHash' in wo && zeile.ipHash !== wo.ipHash) return false return true } vi.mock('@/lib/db', () => ({ db: { loginVersuch: { findMany: async ({ where, orderBy, take, }: { where: Record orderBy?: { zeitpunkt?: 'asc' | 'desc' } take?: number }) => { const treffer = tabelle.filter((zeile) => passt(zeile, where)) treffer.sort((a, b) => orderBy?.zeitpunkt === 'desc' ? b.zeitpunkt.getTime() - a.zeitpunkt.getTime() : a.zeitpunkt.getTime() - b.zeitpunkt.getTime(), ) return take ? treffer.slice(0, take) : treffer }, findFirst: async ({ where }: { where: Record }) => tabelle.find((zeile) => passt(zeile, where)) ?? null, create: async ({ data }: { data: Omit }) => { laufendeNummer += 1 const zeile: Zeile = { id: `z${laufendeNummer}`, email: data.email, ipHash: data.ipHash ?? null, erfolg: data.erfolg, grund: data.grund ?? null, zeitpunkt: new Date(), } tabelle.push(zeile) return zeile }, deleteMany: async ({ where }: { where: Record }) => { const vorher = tabelle.length tabelle = tabelle.filter((zeile) => !passt(zeile, where)) return { count: vorher - tabelle.length } }, }, }, })) const { anmeldungErlaubt, sperreVermerken, versuchFesthalten, fehlversucheZuruecksetzen } = await import('@/lib/auth/ratelimit') const OPFER = 'opfer@example.de' const ANSCHLUSS = '198.51.100.7' const FREMD = '203.0.113.9' const START = new Date('2026-08-24T12:00:00Z').getTime() /** Setzt die Uhr auf START plus so viele Sekunden. */ function beiSekunde(sekunden: number): void { vi.setSystemTime(new Date(START + sekunden * 1000)) } async function fehlversuch(sekunde: number): Promise { beiSekunde(sekunde) await versuchFesthalten(OPFER, ANSCHLUSS, false, 'passwort-falsch') } describe('Anmeldesperre', () => { beforeEach(() => { tabelle = [] laufendeNummer = 0 vi.useFakeTimers() beiSekunde(0) }) it('sperrt erst, wenn das Mass voll ist', async () => { for (let i = 0; i < 4; i += 1) await fehlversuch(i) beiSekunde(10) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(false) await fehlversuch(11) beiSekunde(12) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) }) it('laesst die Frist ablaufen, obwohl die Fehlversuche noch im Zeitfenster stehen', async () => { for (let i = 0; i < 5; i += 1) await fehlversuch(i) beiSekunde(890) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) // Kurz danach offen - und mit vollem Kontingent, nicht mit null // Restversuchen. Das Zeitfenster laeuft noch (3600 s), die fuenf // Fehlversuche stehen also weiterhin in der Tabelle. beiSekunde(905) const frei = await anmeldungErlaubt(OPFER, ANSCHLUSS) expect(frei.gesperrt).toBe(false) if (!frei.gesperrt) expect(frei.verbleibendeVersuche).toBe(5) }) it('laesst sich durch Klopfen waehrend der Sperre nicht verlaengern', async () => { for (let i = 0; i < 5; i += 1) await fehlversuch(i) // Der Angreifer klopft mitten in der Sperre weiter. Genau das hat die // Frist frueher jedes Mal um volle 900 s nach hinten geschoben. for (const sekunde of [100, 300, 600, 880]) { beiSekunde(sekunde) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) await sperreVermerken(OPFER, FREMD) } // Die Frist haengt am fuenften echten Fehlversuch (Sekunde 4), nicht am // letzten Klopfen (Sekunde 880). beiSekunde(905) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(false) }) it('rechnet einen abgewiesenen Aufruf nicht als Fehlversuch', async () => { // Vier echte Fehlversuche - das Mass ist noch nicht voll. for (let i = 0; i < 4; i += 1) await fehlversuch(i) // Dazu ein Vermerk ueber einen abgewiesenen Aufruf. Er steht als // fuenfte Zeile in der Tabelle, darf aber nicht mitzaehlen: Geprueft // wurde bei ihm nichts, also sagt er ueber die Zugangsdaten nichts aus. beiSekunde(5) await sperreVermerken(OPFER, FREMD) expect(tabelle.length).toBe(5) beiSekunde(6) const stand = await anmeldungErlaubt(OPFER, ANSCHLUSS) expect(stand.gesperrt).toBe(false) if (!stand.gesperrt) expect(stand.verbleibendeVersuche).toBe(1) }) it('rechnet die Frist ab dem Versuch, der das Mass vollgemacht hat', async () => { // Vier Fehlversuche ganz frueh, der fuenfte deutlich spaeter. Die // Sperre beginnt beim fuenften - nicht beim ersten und nicht beim // naechsten, der noch kommt. for (let i = 0; i < 4; i += 1) await fehlversuch(i) await fehlversuch(600) beiSekunde(1400) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) beiSekunde(1510) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(false) }) it('schreibt je Sperre hoechstens eine Zeile, egal wie oft geklopft wird', async () => { for (let i = 0; i < 5; i += 1) await fehlversuch(i) const nachSperre = tabelle.length for (let i = 0; i < 200; i += 1) { beiSekunde(10 + i) await sperreVermerken(OPFER, FREMD) } expect(tabelle.length).toBe(nachSperre + 1) }) it('zaehlt die naechste Runde erst nach Ablauf der Sperre wieder hoch', async () => { for (let i = 0; i < 5; i += 1) await fehlversuch(i) await fehlversuch(906) const stand = await anmeldungErlaubt(OPFER, ANSCHLUSS) expect(stand.gesperrt).toBe(false) if (!stand.gesperrt) expect(stand.verbleibendeVersuche).toBe(4) // Vier weitere - dann greift die zweite Sperre. for (let i = 1; i < 5; i += 1) await fehlversuch(906 + i) beiSekunde(915) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) }) it('raeumt die Fehlversuche nach erfolgreicher Anmeldung ab', async () => { for (let i = 0; i < 5; i += 1) await fehlversuch(i) beiSekunde(10) expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) await fehlversucheZuruecksetzen(OPFER) const frei = await anmeldungErlaubt(OPFER, ANSCHLUSS) expect(frei.gesperrt).toBe(false) if (!frei.gesperrt) expect(frei.verbleibendeVersuche).toBe(5) }) })