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:
+3
-2
@@ -9,6 +9,7 @@ import {
|
||||
anmeldungErlaubt,
|
||||
aufMindestdauerWarten,
|
||||
fehlversucheZuruecksetzen,
|
||||
sperreVermerken,
|
||||
versuchFesthalten,
|
||||
} from '@/lib/auth/ratelimit'
|
||||
import { protokollieren } from '@/lib/audit'
|
||||
@@ -110,7 +111,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
// 1. Sperre pruefen, bevor teuer gerechnet wird.
|
||||
const sperre = await anmeldungErlaubt(email, ip)
|
||||
if (sperre.gesperrt) {
|
||||
await versuchFesthalten(email, ip, false, 'gesperrt')
|
||||
await sperreVermerken(email, ip)
|
||||
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
|
||||
}
|
||||
|
||||
@@ -274,7 +275,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
// anmeldet - erraten laesst sich ein Passkey ohnehin nicht.
|
||||
const sperre = await anmeldungErlaubt(benutzer.email, ip)
|
||||
if (sperre.gesperrt) {
|
||||
await versuchFesthalten(benutzer.email, ip, false, 'gesperrt')
|
||||
await sperreVermerken(benutzer.email, ip)
|
||||
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
|
||||
}
|
||||
|
||||
|
||||
+82
-14
@@ -14,8 +14,32 @@ import { ipHashen } from '@/lib/crypto/hash'
|
||||
* Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es
|
||||
* weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
|
||||
* Adressen von einem Anschluss.
|
||||
*
|
||||
* ## Warum abgewiesene Versuche nicht mitzaehlen
|
||||
*
|
||||
* Frueher wurde jeder Aufruf festgehalten, auch der, der wegen einer
|
||||
* laufenden Sperre gar nicht geprueft wurde. Zusammen mit einer Frist, die
|
||||
* vom juengsten Fehlversuch aus gerechnet wurde, ergab das eine
|
||||
* Rueckkopplung: Jeder abgewiesene Aufruf schob die Frist um die volle
|
||||
* Sperrdauer nach hinten. Wer alle zehn Minuten ein falsches Passwort an
|
||||
* eine fremde Adresse schickte, sperrte deren Inhaber dauerhaft aus - ohne
|
||||
* das Passwort zu kennen. Und jeder dieser Aufrufe hinterliess eine Zeile,
|
||||
* die erst der Wartungslauf nach 30 Tagen wieder los wurde.
|
||||
*
|
||||
* Deshalb jetzt zwei getrennte Dinge:
|
||||
*
|
||||
* - Ein *geprueft* fehlgeschlagener Versuch (falsches Passwort, falscher
|
||||
* Code, unbekannte Adresse) zaehlt und wird einzeln festgehalten.
|
||||
* - Ein *abgewiesener* Aufruf waehrend der Sperre zaehlt nicht und wird
|
||||
* hoechstens einmal je Sperre vermerkt.
|
||||
*
|
||||
* Damit steht die Frist fest, sobald sie einmal gesetzt ist, und die
|
||||
* Tabelle waechst nicht mit der Zahl der Angriffsversuche.
|
||||
*/
|
||||
|
||||
/** Grund fuer eine Zeile, die nur den abgewiesenen Aufruf dokumentiert. */
|
||||
const GRUND_ABGEWIESEN = 'gesperrt'
|
||||
|
||||
export type SperrErgebnis =
|
||||
| { gesperrt: false; verbleibendeVersuche: number }
|
||||
| { gesperrt: true; nochSekunden: number }
|
||||
@@ -29,33 +53,49 @@ function fensterStart(): Date {
|
||||
* Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst
|
||||
* Rechenzeit fuer Argon2 kosten.
|
||||
*/
|
||||
export async function anmeldungErlaubt(
|
||||
email: string,
|
||||
ip: string | null,
|
||||
): Promise<SperrErgebnis> {
|
||||
const seit = fensterStart()
|
||||
export async function anmeldungErlaubt(email: string, ip: string | null): Promise<SperrErgebnis> {
|
||||
const jetzt = Date.now()
|
||||
const kennung = email.trim().toLowerCase()
|
||||
const ipH = ipHashen(ip)
|
||||
|
||||
const fehlversuche = await db.loginVersuch.findMany({
|
||||
where: {
|
||||
erfolg: false,
|
||||
zeitpunkt: { gte: seit },
|
||||
// Abgewiesene Aufrufe zaehlen nicht mit - sonst haelt sich die Sperre
|
||||
// selbst am Leben, siehe Kopf dieser Datei.
|
||||
grund: { not: GRUND_ABGEWIESEN },
|
||||
zeitpunkt: { gte: new Date(jetzt - env.loginFensterSekunden * 1000) },
|
||||
OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])],
|
||||
},
|
||||
orderBy: { zeitpunkt: 'desc' },
|
||||
orderBy: { zeitpunkt: 'asc' },
|
||||
select: { zeitpunkt: true },
|
||||
take: env.loginMaxVersuche + 1,
|
||||
// Deckel gegen eine ausufernde Abfrage. Mehr als eine Handvoll Runden
|
||||
// passt ohnehin nicht in ein Zeitfenster, in dem jede Runde gesperrt
|
||||
// wird.
|
||||
take: 200,
|
||||
})
|
||||
|
||||
if (fehlversuche.length >= env.loginMaxVersuche) {
|
||||
const juengster = fehlversuche[0]!.zeitpunkt.getTime()
|
||||
const frei = juengster + env.loginSperreSekunden * 1000
|
||||
const nochSekunden = Math.max(1, Math.ceil((frei - Date.now()) / 1000))
|
||||
return { gesperrt: true, nochSekunden }
|
||||
// Von alt nach neu durchgehen und die Runden nachvollziehen: Sobald das
|
||||
// Mass voll ist, beginnt an genau diesem Versuch eine Sperre, und der
|
||||
// Zaehler faengt von vorn an. Ist ihre Frist inzwischen abgelaufen, sind
|
||||
// die Versuche davor abgegolten - sonst bliebe der Zugang bis zum Ende
|
||||
// des Zeitfensters dicht, obwohl die Sperre laengst um ist.
|
||||
let zaehler = 0
|
||||
let sperreBis = 0
|
||||
|
||||
for (const versuch of fehlversuche) {
|
||||
zaehler += 1
|
||||
if (zaehler >= env.loginMaxVersuche) {
|
||||
sperreBis = versuch.zeitpunkt.getTime() + env.loginSperreSekunden * 1000
|
||||
zaehler = 0
|
||||
}
|
||||
}
|
||||
|
||||
return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - fehlversuche.length }
|
||||
if (sperreBis > jetzt) {
|
||||
return { gesperrt: true, nochSekunden: Math.max(1, Math.ceil((sperreBis - jetzt) / 1000)) }
|
||||
}
|
||||
|
||||
return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - zaehler }
|
||||
}
|
||||
|
||||
/** Haelt einen Versuch fest. Der Grund hilft spaeter beim Nachvollziehen. */
|
||||
@@ -75,6 +115,34 @@ export async function versuchFesthalten(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Vermerkt einen abgewiesenen Aufruf - aber nur den ersten je Sperre.
|
||||
*
|
||||
* Dass eine Sperre gegriffen hat, will man im Protokoll sehen. Wie oft
|
||||
* dagegen geklopft wurde, ist die Information nicht wert: Sie kostet eine
|
||||
* Zeile pro Anfrage, und genau daraus laesst sich die Tabelle vollschreiben.
|
||||
* Eine Zeile je Sperre reicht, um den Vorgang zu erkennen.
|
||||
*
|
||||
* @returns true, wenn tatsaechlich vermerkt wurde.
|
||||
*/
|
||||
export async function sperreVermerken(email: string, ip: string | null): Promise<boolean> {
|
||||
const kennung = email.trim().toLowerCase()
|
||||
const vorhanden = await db.loginVersuch.findFirst({
|
||||
where: {
|
||||
email: kennung,
|
||||
erfolg: false,
|
||||
grund: GRUND_ABGEWIESEN,
|
||||
zeitpunkt: { gte: fensterStart() },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (vorhanden) return false
|
||||
|
||||
await versuchFesthalten(kennung, ip, false, GRUND_ABGEWIESEN)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser
|
||||
* Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen.
|
||||
|
||||
@@ -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