Zugangs-Sperre traf ALLE Besucher gleichzeitig statt nur den Verursacher

Nutzer-Report 19.08.2026: "ich komme auf der .com nicht rein" -- der RICHTIGE Zugangscode
wurde mit "Falscher Zugangscode" abgewiesen.

Ursache: gateClientKey() nutzte req.ip. Das ist hier NICHT die IP des Besuchers, sondern
die des Cloudflare-Knotens (Kette Besucher -> Cloudflare -> Caddy -> Express; bei
trust proxy: 1 bleibt genau Cloudflare uebrig). Damit teilten sich alle Besucher EINEN
Fehlversuchs-Zaehler -- fuenf Vertipper von irgendwem sperrten die Seite fuer jeden,
15 Minuten lang.

Live nachgewiesen, nicht vermutet: derselbe richtige Code wurde ueber
dogfather-universe.com abgelehnt und im selben Moment ueber www.dogfather-universe.com
akzeptiert -- zwei Namen, zwei Cloudflare-Knoten, zwei getrennte Zaehler.

Fix 1 -- echte Besucher-IP aus CF-Connecting-IP. Dieser Header war am 05.08.2026 bewusst
verworfen worden, weil er faelschbar war, solange der Server auch direkt unter seiner IP
erreichbar war. Diese Voraussetzung gilt nicht mehr: die Firewall laesst 80/443 nur noch
aus den Cloudflare-Netzen zu. Vor der Umstellung von aussen gegengeprueft -- Direktzugriff
auf beide Ports kommt gar nicht mehr zustande, der Header kann also nur von Cloudflare
stammen. Abhaengigkeit im Code vermerkt: wird der Direktzugriff je wieder geoeffnet, muss
diese Stelle zurueckgebaut werden. Ungueltige Header-Werte fallen sauber auf req.ip zurueck.

Fix 2 -- ehrliche Meldung bei Sperre (429 statt 401), mit Restzeit in Minuten. Die bisher
absichtlich identische Meldung sollte Angreifern nichts verraten, hat aber in der Praxis
den Besitzer der Seite selbst ratlos gemacht: richtiger Code, Anzeige "Falscher
Zugangscode", keine Chance zu erkennen dass nur eine Wartezeit laeuft. Die Sperre bleibt
in voller Laenge bestehen, der Code wird dadurch nicht leichter erratbar.

Fix 3 -- abgelaufene Eintraege werden aufgeraeumt. Pro echter Besucher-IP kann die Map
sonst unbegrenzt wachsen (vorher gab es nur eine Handvoll Cloudflare-Knoten).

Regressionstest ergaenzt (server/test-gate.mjs, 12 Pruefungen). Gegen den ALTEN Code
laufen gezielt 5 davon auf Fehler -- darunter "Dogi kommt trotz fremder Sperre rein" --,
gegen den neuen alle gruen. Der Test faengt also wirklich diesen Bug.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-19 20:21:46 +02:00
co-authored by Claude Opus 5
parent 80dbfc9367
commit 50371d5404
2 changed files with 152 additions and 4 deletions
+67 -4
View File
@@ -95,7 +95,39 @@ const GATE_MAX_ATTEMPTS = 5;
const GATE_LOCKOUT_MS = 15 * 60 * 1000;
const gateAttempts = new Map(); // ip -> { count, lockedUntil }
/* KORREKTUR (19.08.2026) — die Sperre traf bisher ALLE Besucher gleichzeitig.
Nutzer-Report: "ich komme auf dem Handy nicht rein", richtiger Code wurde als falsch
abgewiesen. Ursache: req.ip liefert hier NICHT die IP des Besuchers, sondern die des
Cloudflare-Servers, ueber den alle laufen (Kette: Besucher -> Cloudflare -> Caddy ->
Express; bei `trust proxy: 1` bleibt genau der Cloudflare-Knoten uebrig). Alle Besucher
teilten sich dadurch EINEN Fehlversuchs-Zaehler: fuenf Vertipper von irgendwem sperrten
die Seite fuer jeden. Live nachgewiesen -- derselbe richtige Code wurde ueber
dogfather-universe.com abgelehnt und ueber www.dogfather-universe.com akzeptiert, weil
die beiden Namen ueber unterschiedliche Cloudflare-Knoten liefen (= zwei Zaehler).
Loesung: die echte Besucher-IP aus `CF-Connecting-IP` nehmen. Am 05.08.2026 war genau
dieser Header bewusst VERWORFEN worden, weil er faelschbar war, solange der Server auch
direkt unter seiner IP erreichbar war. Diese Voraussetzung hat sich seither geaendert:
die Firewall laesst Port 80/443 nur noch aus den Cloudflare-Netzen zu (am 19.08.2026 von
aussen gegengeprueft: Direktzugriff auf beide Ports kommt gar nicht mehr zustande).
Damit kann der Header nur noch von Cloudflare selbst gesetzt werden, und Cloudflare
ueberschreibt ihn immer.
⚠️ ABHAENGIGKEIT: Sollte der Direktzugriff auf den Server jemals wieder geoeffnet werden,
wird dieser Header wieder faelschbar -- dann muss diese Stelle zurueckgebaut werden. */
function istIpAdresse(wert) {
if (typeof wert !== "string") return false;
const s = wert.trim();
if (!s || s.length > 45) return false;
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv6 = /^[0-9a-fA-F:]+$/;
return ipv4.test(s) || (s.includes(":") && ipv6.test(s));
}
function gateClientKey(req) {
const cf = req.headers["cf-connecting-ip"];
if (istIpAdresse(cf)) return cf.trim();
// Kein Cloudflare davor (z.B. lokaler Test) -> alter Weg, weiterhin nicht faelschbar.
return req.ip || req.socket?.remoteAddress || "unknown";
}
@@ -104,9 +136,28 @@ function isGateLockedOut(key) {
return !!(entry && entry.lockedUntil && entry.lockedUntil > Date.now());
}
/* Wie lange die Sperre noch laeuft (aufgerundete Minuten, mindestens 1). */
function gateSperreRestMinuten(key) {
const entry = gateAttempts.get(key);
if (!entry || !entry.lockedUntil) return 0;
return Math.max(1, Math.ceil((entry.lockedUntil - Date.now()) / 60000));
}
/* Seit die Zaehlung pro echter Besucher-IP laeuft, kann die Map theoretisch unbegrenzt
wachsen (vorher gab es nur eine Handvoll Cloudflare-Knoten). Deshalb bei jeder Anmeldung
abgelaufene Eintraege wegraeumen -- kein Timer noetig, kostet praktisch nichts. */
function gateAufraeumen() {
const jetzt = Date.now();
for (const [k, e] of gateAttempts) {
const abgelaufen = (!e.lockedUntil || e.lockedUntil < jetzt) && (e.zuletzt || 0) < jetzt - GATE_LOCKOUT_MS;
if (abgelaufen) gateAttempts.delete(k);
}
}
function recordGateFailedAttempt(key) {
const entry = gateAttempts.get(key) || { count: 0, lockedUntil: null };
entry.count += 1;
entry.zuletzt = Date.now();
if (entry.count >= GATE_MAX_ATTEMPTS) entry.lockedUntil = Date.now() + GATE_LOCKOUT_MS;
gateAttempts.set(key, entry);
}
@@ -117,11 +168,23 @@ function clearGateAttempts(key) {
export async function gateAuthHandler(req, res) {
const clientKey = gateClientKey(req);
// Absichtlich DIESELBE Fehlermeldung wie bei einem falschen Code (siehe unten) -- ein Angreifer
// soll aus der Antwort nicht ablesen koennen, ob er gerade gesperrt ist oder nur falsch getippt
// hat (das wuerde sonst verraten, wie nah man an der Sperrschwelle dran ist).
gateAufraeumen();
/* GEAENDERT 19.08.2026 (Nutzer-Entscheidung): Bisher kam bei einer Sperre absichtlich exakt
dieselbe Meldung wie bei einem falschen Code, damit ein Angreifer nicht erkennt, wie nah er
an der Sperrschwelle ist. In der Praxis hat genau das den Besitzer der Seite selbst ratlos
gemacht -- er tippte den RICHTIGEN Code ein und las "Falscher Zugangscode", ohne jede
Chance zu erkennen, dass nur eine Wartezeit lief. Jetzt eine ehrliche Meldung mit Restzeit.
Der Sicherheitsverlust ist gering: der Code selbst wird dadurch nicht leichter erratbar, die
Sperre bleibt in voller Laenge bestehen, und wer sie ausloest, erfaehrt lediglich, dass er
warten muss (was er ohnehin merkt). Der Gewinn -- niemand haelt eine Wartezeit faelschlich
fuer einen falschen Code -- wiegt hier deutlich schwerer. */
if (isGateLockedOut(clientKey)) {
return res.status(401).json({ ok: false, error: "Falscher Zugangscode." });
const min = gateSperreRestMinuten(clientKey);
return res.status(429).json({
ok: false,
error: `Zu viele Fehlversuche. Bitte ${min} Minute${min === 1 ? "" : "n"} warten – dein Code ist danach wieder gültig.`,
});
}
const body = req.body || {};
+85
View File
@@ -0,0 +1,85 @@
/* Test der Zugangs-Sperre (19.08.2026).
Stellt den echten Fehlerfall nach: Frueher teilten sich ALLE Besucher einen einzigen
Fehlversuchs-Zaehler, weil req.ip die Cloudflare-IP war statt der des Besuchers.
Ohne echten Server -- gateAuthHandler wird direkt mit nachgebauten req/res aufgerufen. */
process.env.SITE_ACCESS_SECRET = "test-geheimnis-nur-fuer-diesen-test";
process.env.SITE_ACCESS_CODE_DOGI = "RichtigerCode";
process.env.SITE_ACCESS_CODE_VANVAN = "VanVanCode";
const { gateAuthHandler } = await import("./gate.js");
function anfrage(code, besucherIp) {
return {
headers: { "cf-connecting-ip": besucherIp },
ip: "172.71.0.1", // immer DIESELBE Cloudflare-IP - genau das war der Ausloeser
body: { code },
};
}
function antwort() {
const r = {
statusCode: 200,
daten: null,
cookies: {},
status(c) { r.statusCode = c; return r; },
json(d) { r.daten = d; return r; },
cookie(n, w) { r.cookies[n] = w; return r; },
};
return r;
}
async function versuch(code, ip) {
const res = antwort();
await gateAuthHandler(anfrage(code, ip), res);
return res;
}
let fehler = 0;
function pruefe(bedingung, text) {
console.log(` ${bedingung ? "OK " : "FEHL"} ${text}`);
if (!bedingung) fehler++;
}
console.log("=== 1. Richtiger Code wird angenommen ===");
let r = await versuch("RichtigerCode", "5.5.5.5");
pruefe(r.daten?.ok === true, "richtiger Code -> ok");
pruefe(!!r.cookies.dogi_session, "Sitzungs-Cookie wird gesetzt");
pruefe(r.cookies.dogi_role === "dogi", "Rolle 'dogi' erkannt");
console.log("\n=== 2. VanVan kommt ebenfalls rein ===");
r = await versuch("VanVanCode", "5.5.5.6");
pruefe(r.daten?.ok === true && r.cookies.dogi_role === "vanvan", "VanVan-Code -> ok, Rolle 'vanvan'");
console.log("\n=== 3. Falscher Code wird abgewiesen ===");
r = await versuch("Quatsch", "5.5.5.7");
pruefe(r.daten?.ok === false && r.statusCode === 401, "falscher Code -> 401");
console.log("\n=== 4. Sperre nach 5 Fehlversuchen (Angreifer-IP 9.9.9.9) ===");
for (let i = 0; i < 5; i++) await versuch("falsch" + i, "9.9.9.9");
r = await versuch("nochmal", "9.9.9.9");
pruefe(r.statusCode === 429, "gesperrte IP -> 429 (statt irrefuehrend 401)");
pruefe(/Zu viele Fehlversuche/.test(r.daten?.error || ""), "ehrliche Meldung statt 'Falscher Zugangscode'");
pruefe(/Minute/.test(r.daten?.error || ""), "Restzeit wird genannt");
console.log(` Meldung: "${r.daten?.error}"`);
console.log("\n=== 5. DER EIGENTLICHE BUG: sperrt das auch Dogi aus? ===");
r = await versuch("RichtigerCode", "5.5.5.5");
pruefe(r.daten?.ok === true, "Dogi kommt trotz fremder Sperre rein (frueher: NEIN)");
console.log("\n=== 6. Auch der RICHTIGE Code bleibt fuer die gesperrte IP gesperrt ===");
r = await versuch("RichtigerCode", "9.9.9.9");
pruefe(r.statusCode === 429, "gesperrte IP kommt auch mit richtigem Code nicht rein");
console.log("\n=== 7. Ohne Cloudflare-Header: Rueckfall auf req.ip ===");
const res7 = antwort();
await gateAuthHandler({ headers: {}, ip: "10.0.0.5", body: { code: "RichtigerCode" } }, res7);
pruefe(res7.daten?.ok === true, "funktioniert auch ohne Cloudflare-Header");
console.log("\n=== 8. Gefaelschter Header wird nicht blind uebernommen ===");
const res8 = antwort();
await gateAuthHandler({ headers: { "cf-connecting-ip": "kein-ip-wert; DROP TABLE" }, ip: "10.0.0.6", body: { code: "RichtigerCode" } }, res8);
pruefe(res8.daten?.ok === true, "unsinniger Header -> sauberer Rueckfall, kein Absturz");
console.log(fehler === 0 ? "\n=> ALLE TESTS BESTANDEN" : `\n=> ${fehler} TESTS FEHLGESCHLAGEN`);
process.exit(fehler === 0 ? 0 : 1);