diff --git a/server/gate.js b/server/gate.js index 2a44ae72..c48d23a3 100644 --- a/server/gate.js +++ b/server/gate.js @@ -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 || {}; diff --git a/server/test-gate.mjs b/server/test-gate.mjs new file mode 100644 index 00000000..7d6bff81 --- /dev/null +++ b/server/test-gate.mjs @@ -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);