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" });