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
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user