Sicherheitskorrekturen am Gate + Mobil-Scroll-Bugfix (Audit-Funde 07.08.2026)

This commit is contained in:
Dogi
2026-08-07 01:21:20 +02:00
parent d6489f787a
commit 7201c6a300
2 changed files with 97 additions and 11 deletions
+16 -2
View File
@@ -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) ---------- */
+81 -9
View File
@@ -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" });