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
93 lines
3.2 KiB
TypeScript
93 lines
3.2 KiB
TypeScript
import 'server-only'
|
|
import { env } from '@/lib/env'
|
|
|
|
/**
|
|
* Von welchem Anschluss kommt diese Anfrage?
|
|
*
|
|
* Gebraucht wird das nur fuer eines: die zweite Dimension der Anmeldesperre.
|
|
* Gezaehlt wird nach E-Mail UND nach Anschluss, damit es weder hilft,
|
|
* dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
|
|
* Adressen von einem.
|
|
*
|
|
* ## Warum das vorher nichts geschuetzt hat
|
|
*
|
|
* Der Wert kam aus "x-forwarded-for", und zwar aus dem ERSTEN Eintrag:
|
|
*
|
|
* kopf.get('x-forwarded-for')?.split(',')[0]?.trim()
|
|
*
|
|
* Dieser Kopf ist eine Liste, an die jeder Zwischenknoten hinten anhaengt.
|
|
* Der erste Eintrag ist deshalb der, den der Aufrufer selbst geschickt hat -
|
|
* frei erfunden. Wer bei jeder Anfrage eine andere Zahl hineinschrieb, sah
|
|
* fuer die Sperre wie tausend verschiedene Anschluesse aus. Die Zaehlung
|
|
* nach Anschluss war damit nicht nur wirkungslos, sondern irrefuehrend: Im
|
|
* Protokoll standen tausend Anschluesse, wo einer war.
|
|
*
|
|
* Richtig ist das andere Ende: Was der vertraute Proxy anhaengt, hat er
|
|
* selbst gesehen und nicht der Aufrufer geschrieben.
|
|
*
|
|
* ## Und wenn kein Proxy davor steht
|
|
*
|
|
* Dann ist der Kopf vollstaendig fremdbestimmt und es gibt hier nichts zu
|
|
* holen - also null. Keine Zaehlung nach Anschluss ist besser als eine, die
|
|
* sich mit einer Kopfzeile aushebeln laesst. Deshalb muss der Betrieb
|
|
* ausdruecklich sagen, dass ein Proxy davorsteht (VERTRAUE_PROXY=true).
|
|
*/
|
|
|
|
let gewarnt = false
|
|
|
|
/**
|
|
* Warnt einmal, wenn weitergereichte Koepfe ankommen, ihnen aber nicht
|
|
* geglaubt wird. Genau dann fehlt die Einstellung im Betrieb.
|
|
*/
|
|
function einmalWarnen(): void {
|
|
if (gewarnt) return
|
|
gewarnt = true
|
|
console.warn(
|
|
'[anschluss] Es kommen "x-forwarded-for"-Koepfe an, aber VERTRAUE_PROXY ist nicht gesetzt. ' +
|
|
'Die Anmeldesperre zaehlt deshalb nur nach E-Mail-Adresse, nicht nach Anschluss. ' +
|
|
'Steht ein Reverse Proxy davor, setze VERTRAUE_PROXY=true (und, bei mehreren, ' +
|
|
'VERTRAUE_PROXY_STUFEN=<Anzahl>). Steht keiner davor, ist das hier richtig so.',
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Liest die Adresse des Aufrufers aus den Koepfen.
|
|
*
|
|
* @param kopf Die Kopfzeilen der Anfrage.
|
|
* @returns Die Adresse, oder null, wenn sie sich nicht vertrauenswuerdig
|
|
* bestimmen laesst.
|
|
*/
|
|
export function anschlussAus(kopf: Headers): string | null {
|
|
const weitergereicht = kopf.get('x-forwarded-for')
|
|
const echterIp = kopf.get('x-real-ip')
|
|
|
|
if (!env.vertraueProxy) {
|
|
if (weitergereicht || echterIp) einmalWarnen()
|
|
return null
|
|
}
|
|
|
|
if (weitergereicht) {
|
|
const kette = weitergereicht
|
|
.split(',')
|
|
.map((teil) => teil.trim())
|
|
.filter((teil) => teil.length > 0)
|
|
|
|
// Von hinten zaehlen: Der letzte Eintrag stammt vom naechsten
|
|
// Zwischenknoten, der zweitletzte von dem davor. Bei einer Stufe ist
|
|
// der letzte Eintrag der Aufrufer, wie der Proxy ihn gesehen hat.
|
|
if (kette.length > 0) {
|
|
const stelle = kette.length - env.vertraueProxyStufen
|
|
return kette[Math.max(0, stelle)] ?? null
|
|
}
|
|
}
|
|
|
|
// Manche Proxys setzen nur diesen Kopf. Er enthaelt genau eine Adresse,
|
|
// also gibt es hier nichts abzuzaehlen.
|
|
return echterIp?.trim() || null
|
|
}
|
|
|
|
/** Nur fuer Tests: setzt die einmalige Warnung zurueck. */
|
|
export function warnungZuruecksetzen(): void {
|
|
gewarnt = false
|
|
}
|