Files
Florian Wilde 609aa5d27d Anmeldung haerten: Einmalcodes, Anschluss, Host, Passkey-Vorzimmer
Vier Stellen, an denen eine Zusage nicht gehalten wurde.

1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit
   dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan
   hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90
   Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch
   zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von
   updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen
   nicht beide eine Zeile.

2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das
   ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage
   eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend
   Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern
   irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY
   gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine
   Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim
   ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt.

3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der
   dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen
   Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten.
   AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
   nicht stillschweigend uebergangen wird.

4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen.
   Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel
   ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks.
   Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste
   alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst
   verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft
   werden".

Refs #38
2026-08-24 08:03:33 +02:00

137 lines
4.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { anschlussAus, warnungZuruecksetzen } from '@/lib/auth/anschluss'
/**
* Aus welchem Kopf die Adresse des Aufrufers gelesen wird.
*
* Vorher stand dort der ERSTE Eintrag von "x-forwarded-for". Das ist die
* klassische Verwechslung: Der Kopf ist eine Liste, an die jeder
* Zwischenknoten hinten anhaengt - der erste Eintrag ist also der, den der
* Aufrufer selbst mitgeschickt hat. Wer bei jeder Anfrage eine andere Zahl
* hineinschrieb, sah fuer die Anmeldesperre wie tausend Anschluesse aus.
*/
function koepfe(werte: Record<string, string>): Headers {
return new Headers(werte)
}
describe('Anschluss des Aufrufers', () => {
beforeEach(() => {
warnungZuruecksetzen()
delete process.env.VERTRAUE_PROXY
delete process.env.VERTRAUE_PROXY_STUFEN
})
afterEach(() => {
delete process.env.VERTRAUE_PROXY
delete process.env.VERTRAUE_PROXY_STUFEN
vi.restoreAllMocks()
})
describe('ohne vertrauten Proxy', () => {
it('glaubt dem weitergereichten Kopf nicht', () => {
const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(anschlussAus(koepfe({ 'x-forwarded-for': '203.0.113.9' }))).toBeNull()
expect(warnung).toHaveBeenCalledOnce()
})
it('warnt nur einmal, nicht bei jeder Anfrage', () => {
const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {})
for (let i = 0; i < 20; i += 1) {
anschlussAus(koepfe({ 'x-forwarded-for': `203.0.113.${i}` }))
}
expect(warnung).toHaveBeenCalledOnce()
})
it('schweigt, wenn gar kein solcher Kopf ankommt', () => {
const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(anschlussAus(koepfe({}))).toBeNull()
expect(warnung).not.toHaveBeenCalled()
})
})
describe('mit einem vertrauten Proxy', () => {
beforeEach(() => {
process.env.VERTRAUE_PROXY = 'true'
})
it('nimmt den letzten Eintrag, nicht den ersten', () => {
// Links das, was der Aufrufer selbst behauptet hat; rechts das, was
// der Proxy gesehen hat. Nur letzteres ist etwas wert.
const kopf = koepfe({ 'x-forwarded-for': '10.0.0.1, 198.51.100.7' })
expect(anschlussAus(kopf)).toBe('198.51.100.7')
})
it('laesst sich durch eine erfundene Kette nicht taeuschen', () => {
const kopf = koepfe({
'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3, 198.51.100.7',
})
expect(anschlussAus(kopf)).toBe('198.51.100.7')
})
it('nimmt den einzigen Eintrag, wenn der Aufrufer keinen mitschickte', () => {
expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7')
})
it('faellt auf x-real-ip zurueck', () => {
expect(anschlussAus(koepfe({ 'x-real-ip': '198.51.100.7' }))).toBe('198.51.100.7')
})
it('gibt null, wenn nichts da ist', () => {
expect(anschlussAus(koepfe({}))).toBeNull()
})
it('kommt mit Leerraum und leeren Gliedern zurecht', () => {
const kopf = koepfe({ 'x-forwarded-for': ' 10.0.0.1 , , 198.51.100.7 ' })
expect(anschlussAus(kopf)).toBe('198.51.100.7')
})
})
describe('mit mehreren Stufen', () => {
it('zaehlt die angegebene Anzahl von hinten ab', () => {
process.env.VERTRAUE_PROXY = 'true'
process.env.VERTRAUE_PROXY_STUFEN = '2'
// Zwei eigene Zwischenknoten: der aeussere haengt 198.51.100.7 an,
// der innere 10.0.0.5. Der Aufrufer steht eine Stelle weiter links.
const kopf = koepfe({ 'x-forwarded-for': 'erfunden, 198.51.100.7, 10.0.0.5' })
expect(anschlussAus(kopf)).toBe('198.51.100.7')
})
it('greift nicht ueber den Anfang der Kette hinaus', () => {
process.env.VERTRAUE_PROXY = 'true'
process.env.VERTRAUE_PROXY_STUFEN = '9'
expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7')
})
})
describe('der Schalter selbst', () => {
it('gilt nur bei ausdruecklichem Ja', () => {
for (const wert of ['false', 'nein', '0', 'vielleicht', '']) {
warnungZuruecksetzen()
vi.spyOn(console, 'warn').mockImplementation(() => {})
process.env.VERTRAUE_PROXY = wert
expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBeNull()
}
})
it('nimmt true, 1 und ja', () => {
for (const wert of ['true', '1', 'ja', 'TRUE', ' Ja ']) {
process.env.VERTRAUE_PROXY = wert
expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7')
}
})
})
})