diff --git a/gate.html b/gate.html index b464440..50274e8 100644 --- a/gate.html +++ b/gate.html @@ -25,9 +25,23 @@ ===================================================================== */ html, body { height: 100%; } body { - display: flex; align-items: center; justify-content: center; + /* align-items:center zuerst als Fallback fuer sehr alte Browser, die "safe" nicht + kennen (eine ungueltige Deklaration wird ignoriert, die vorige bleibt bestehen) -- + align-items:safe center sorgt dafuer, dass zentrierter Inhalt beim Scrollen nie am + Anfang "abgeschnitten" wirkt. */ + display: flex; align-items: center; align-items: safe center; justify-content: center; min-height: 100vh; padding: 2rem 1.2rem; position: relative; - overflow: hidden; background: radial-gradient(ellipse at 50% 30%, #12161d 0%, #06070a 62%, #030304 100%); + /* BUGFIX (07.08.2026, Nutzer-Report vom Handy: "Code eintippen geht, aber dann ist + der 'Weiter'-Knopf nirgendwo"): overflow:hidden auf dem BODY verhinderte jegliches + Scrollen -- sobald die virtuelle Tastatur einen grossen Teil des Bildschirms + einnimmt, ragte der Knopf unterhalb der sichtbaren Flaeche heraus und war komplett + unerreichbar, ohne jede Moeglichkeit hinzuscrollen (mit echter Tastatur-Simulation + nachgestellt und bestaetigt). overflow-y:auto erlaubt Scrollen NUR wenn wirklich + noetig -- im Normalfall ohne Tastatur aendert sich optisch nichts. overflow-x + bleibt hidden, das war der eigentliche urspruengliche Zweck (verhindert einen + ungewollten horizontalen Scrollbalken durch die Aurora-Nebel-Kreise, die + absichtlich ueber den Bildschirmrand hinausragen). */ + overflow-x: hidden; overflow-y: auto; background: radial-gradient(ellipse at 50% 30%, #12161d 0%, #06070a 62%, #030304 100%); } /* ---------- Aurora-Nebel, langsam driftend (3 Farbtöne der Marke) ---------- */ diff --git a/server/gate.js b/server/gate.js index 88bd852..2a44ae7 100644 --- a/server/gate.js +++ b/server/gate.js @@ -53,19 +53,91 @@ export async function verifySession(token, secret) { } } +/* SICHERHEITSKORREKTUR (07.08.2026, Audit-Fund): "next" kam bisher direkt vom Besucher (Query- + Parameter in gate.html -> POST-Body hier -> unveraendert in der Antwort zurueck -> Browser + macht `location.href = data.next`). Die Pruefung "faengt mit / an und ist nicht genau /" laesst + AUCH "//boese-seite.de" durch -- das ist eine protokoll-relative URL, der Browser haengt das + aktuelle https:// davor und navigiert tatsaechlich auf die fremde Seite. Ein praeparierter Link + wie "https://dogfather-universe.com/gate.html?next=%2F%2Fboese-seite.de" saehe aus wie die + echte Seite (echte Domain!), wuerde nach der Code-Eingabe aber still auf eine Phishing-Seite + umleiten. Fix: zusaetzlich "//" und "/\" (das interpretieren manche Browser genauso) explizit + ausschliessen -- "next" darf NUR noch ein echter, eigener Pfad sein. */ +function isSafeNextPath(value) { + return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\") && value !== "/"; +} + +/* Zeitsicherer Vergleich (07.08.2026, Audit-Fund) -- genau dasselbe Prinzip wie schon in + server-internal/lib/auth.js: normales "===" bricht bei der ersten abweichenden Stelle ab, was + theoretisch ueber viele Anfragen einen winzigen Zeitunterschied messbar macht. Praktisch schwer + auszunutzen, aber kostenlos zu vermeiden -- die Schleife laeuft immer komplett durch. */ +function timingSafeEqual(a, b) { + a = String(a == null ? "" : a); + b = String(b == null ? "" : b); + const len = Math.max(a.length, b.length); + let mismatch = a.length === b.length ? 0 : 1; + for (let i = 0; i < len; i++) { + mismatch |= (i < a.length ? a.charCodeAt(i) : 0) ^ (i < b.length ? b.charCodeAt(i) : 0); + } + return mismatch === 0; +} + +/* SICHERHEITSKORREKTUR (07.08.2026, Audit-Fund): bisher gab es HIER keine Bremse gegen + automatisiertes Durchprobieren des Zugangscodes -- im Gegensatz zum internen Team-Bereich + (server-internal/lib/auth.js), der schon eine 5-Versuche/15-Minuten-Sperre hat. Absichtlich + EINFACHER gehalten als dort: dieser Server hat (noch) keine Datenbank, eine Sperre im + Arbeitsspeicher (Map, IP -> Zaehler) reicht fuer diesen Zweck voellig aus -- der einzige + Nachteil ist, dass die Zaehlung bei einem Server-Neustart zuruecksetzt, was hier unkritisch ist + (kein Angriff ueberlebt einen Neustart laenger als der ohnehin sehr seltene Fall, dass genau + dann neu gestartet wird). req.ip ist dank `app.set("trust proxy", 1)` in index.js + genau einer + vertrauten Proxy-Stufe (Caddy) nicht vom Client faelschbar -- exakt dieselbe Absicherung wie + beim internen Bereich (siehe dortiger Kommentar vom 05.08.2026 zur CF-Connecting-IP-Luecke). */ +const GATE_MAX_ATTEMPTS = 5; +const GATE_LOCKOUT_MS = 15 * 60 * 1000; +const gateAttempts = new Map(); // ip -> { count, lockedUntil } + +function gateClientKey(req) { + return req.ip || req.socket?.remoteAddress || "unknown"; +} + +function isGateLockedOut(key) { + const entry = gateAttempts.get(key); + return !!(entry && entry.lockedUntil && entry.lockedUntil > Date.now()); +} + +function recordGateFailedAttempt(key) { + const entry = gateAttempts.get(key) || { count: 0, lockedUntil: null }; + entry.count += 1; + if (entry.count >= GATE_MAX_ATTEMPTS) entry.lockedUntil = Date.now() + GATE_LOCKOUT_MS; + gateAttempts.set(key, entry); +} + +function clearGateAttempts(key) { + gateAttempts.delete(key); +} + export async function gateAuthHandler(req, res) { - const body = req.body || {}; - const code = (body.code || "").trim(); - const next = typeof body.next === "string" && body.next.startsWith("/") && body.next !== "/" ? body.next : "/index.html"; - - let role = null; - if (process.env.SITE_ACCESS_CODE_DOGI && code === process.env.SITE_ACCESS_CODE_DOGI) role = "dogi"; - else if (process.env.SITE_ACCESS_CODE_VANVAN && code === process.env.SITE_ACCESS_CODE_VANVAN) role = "vanvan"; - - if (!role) { + 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). + if (isGateLockedOut(clientKey)) { return res.status(401).json({ ok: false, error: "Falscher Zugangscode." }); } + const body = req.body || {}; + const code = (body.code || "").trim(); + const next = isSafeNextPath(body.next) ? body.next : "/index.html"; + + let role = null; + if (process.env.SITE_ACCESS_CODE_DOGI && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_DOGI)) role = "dogi"; + else if (process.env.SITE_ACCESS_CODE_VANVAN && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_VANVAN)) role = "vanvan"; + + if (!role) { + recordGateFailedAttempt(clientKey); + return res.status(401).json({ ok: false, error: "Falscher Zugangscode." }); + } + clearGateAttempts(clientKey); + const session = await signSession(role, process.env.SITE_ACCESS_SECRET); // Bewusst OHNE maxAge/expires → reines Sitzungs-Cookie, verfällt beim Schließen des Browsers. res.cookie(COOKIE_NAME, session, { path: "/", httpOnly: true, secure: true, sameSite: "lax" });