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