Files
Florian Wilde 133b0314c5 Anmeldesperre gegen Selbstverlaengerung und Schreiblast sichern
Die Sperre hielt sich selbst am Leben. Jeder Aufruf wurde festgehalten,
auch der, der wegen der laufenden Sperre gar nicht geprueft wurde; die
Frist lief zugleich ab dem juengsten Fehlversuch. Wer also alle zehn
Minuten ein falsches Passwort an eine fremde Adresse schickte, sperrte
deren Inhaber dauerhaft aus - ohne das Passwort zu kennen. Jeder dieser
Aufrufe hinterliess ausserdem eine Zeile, die erst der Wartungslauf nach
30 Tagen wieder los wurde.

Jetzt zwei getrennte Dinge: Ein geprueft fehlgeschlagener Versuch zaehlt
und wird einzeln festgehalten. Ein waehrend der Sperre abgewiesener
Aufruf zaehlt nicht und wird hoechstens einmal je Sperre vermerkt.

Die Auswertung geht die Versuche von alt nach neu durch und zieht die
Runden nach: Ist das Mass voll, beginnt an diesem Versuch eine Sperre
und der Zaehler faengt von vorn an. Damit laeuft die Frist auch dann ab,
wenn die Fehlversuche noch im Zeitfenster stehen - vorher blieb der
Zugang bis zum Fensterende dicht.

Die Tests sind gegen vier Mutationen geprueft; jede wird rot.

Refs #38
2026-08-24 02:13:04 +02:00

251 lines
8.4 KiB
TypeScript

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<string, unknown>): 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<string, unknown>[]
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<string, unknown>
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<string, unknown> }) =>
tabelle.find((zeile) => passt(zeile, where)) ?? null,
create: async ({ data }: { data: Omit<Zeile, 'id' | 'zeitpunkt'> }) => {
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<string, unknown> }) => {
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<void> {
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)
})
})