Nutzer-Report 19.08.2026: "ich komme auf der .com nicht rein" -- der RICHTIGE Zugangscode wurde mit "Falscher Zugangscode" abgewiesen. Ursache: gateClientKey() nutzte req.ip. Das ist hier NICHT die IP des Besuchers, sondern die des Cloudflare-Knotens (Kette Besucher -> Cloudflare -> Caddy -> Express; bei trust proxy: 1 bleibt genau Cloudflare uebrig). Damit teilten sich alle Besucher EINEN Fehlversuchs-Zaehler -- fuenf Vertipper von irgendwem sperrten die Seite fuer jeden, 15 Minuten lang. Live nachgewiesen, nicht vermutet: derselbe richtige Code wurde ueber dogfather-universe.com abgelehnt und im selben Moment ueber www.dogfather-universe.com akzeptiert -- zwei Namen, zwei Cloudflare-Knoten, zwei getrennte Zaehler. Fix 1 -- echte Besucher-IP aus CF-Connecting-IP. Dieser Header war am 05.08.2026 bewusst verworfen worden, weil er faelschbar war, solange der Server auch direkt unter seiner IP erreichbar war. Diese Voraussetzung gilt nicht mehr: die Firewall laesst 80/443 nur noch aus den Cloudflare-Netzen zu. Vor der Umstellung von aussen gegengeprueft -- Direktzugriff auf beide Ports kommt gar nicht mehr zustande, der Header kann also nur von Cloudflare stammen. Abhaengigkeit im Code vermerkt: wird der Direktzugriff je wieder geoeffnet, muss diese Stelle zurueckgebaut werden. Ungueltige Header-Werte fallen sauber auf req.ip zurueck. Fix 2 -- ehrliche Meldung bei Sperre (429 statt 401), mit Restzeit in Minuten. Die bisher absichtlich identische Meldung sollte Angreifern nichts verraten, hat aber in der Praxis den Besitzer der Seite selbst ratlos gemacht: richtiger Code, Anzeige "Falscher Zugangscode", keine Chance zu erkennen dass nur eine Wartezeit laeuft. Die Sperre bleibt in voller Laenge bestehen, der Code wird dadurch nicht leichter erratbar. Fix 3 -- abgelaufene Eintraege werden aufgeraeumt. Pro echter Besucher-IP kann die Map sonst unbegrenzt wachsen (vorher gab es nur eine Handvoll Cloudflare-Knoten). Regressionstest ergaenzt (server/test-gate.mjs, 12 Pruefungen). Gegen den ALTEN Code laufen gezielt 5 davon auf Fehler -- darunter "Dogi kommt trotz fremder Sperre rein" --, gegen den neuen alle gruen. Der Test faengt also wirklich diesen Bug. Co-Authored-By: Claude Opus 5 <[email protected]>
243 lines
12 KiB
JavaScript
243 lines
12 KiB
JavaScript
/* =====================================================================
|
||
gate.js — Zugangs-Schranke vor der ganzen statischen Website. 1:1 portiert aus
|
||
gate-worker.js (Cloudflare Worker), auf Express-Middleware umgestellt. Gleiche Logik:
|
||
HMAC-signierte Session-Cookies, Fail-open falls Secrets fehlen, gate.html + Assets immer
|
||
erreichbar, "/" wird intern auf "/index.html" abgebildet.
|
||
===================================================================== */
|
||
|
||
export const COOKIE_NAME = "dogi_session";
|
||
export const ROLE_COOKIE_NAME = "dogi_role";
|
||
|
||
/* Zugang gilt nur für die laufende Browser-Sitzung (Wunsch 05.08.2026: "jedes Mal wenn wir den
|
||
Browser zu machen, soll der Code wieder gefragt werden"). Umgesetzt über ein reines
|
||
Sitzungs-Cookie OHNE maxAge/expires — das löscht der Browser beim Schließen selbst.
|
||
SESSION_STUNDEN ist nur die zusätzliche Notbremse im signierten Token für den Fall, dass ein
|
||
Browser tage-/wochenlang offen bleibt (dann läuft der Zugang trotzdem ab). */
|
||
export const SESSION_STUNDEN = 12;
|
||
|
||
function b64urlEncode(bytes) {
|
||
let bin = "";
|
||
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||
}
|
||
function b64urlDecodeToBytes(str) {
|
||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||
while (str.length % 4) str += "=";
|
||
const bin = atob(str);
|
||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||
}
|
||
async function hmacKey(secret) {
|
||
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
||
}
|
||
|
||
export async function signSession(role, secret) {
|
||
const payload = JSON.stringify({ role, exp: Date.now() + SESSION_STUNDEN * 60 * 60 * 1000 });
|
||
const payloadB64 = b64urlEncode(new TextEncoder().encode(payload));
|
||
const key = await hmacKey(secret);
|
||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64));
|
||
return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`;
|
||
}
|
||
|
||
export async function verifySession(token, secret) {
|
||
if (!token || !token.includes(".")) return null;
|
||
const [payloadB64, sigB64] = token.split(".");
|
||
try {
|
||
const key = await hmacKey(secret);
|
||
const valid = await crypto.subtle.verify("HMAC", key, b64urlDecodeToBytes(sigB64), new TextEncoder().encode(payloadB64));
|
||
if (!valid) return null;
|
||
const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64)));
|
||
if (!payload.exp || payload.exp < Date.now()) return null;
|
||
return payload;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/* 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 }
|
||
|
||
/* 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";
|
||
}
|
||
|
||
function isGateLockedOut(key) {
|
||
const entry = gateAttempts.get(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);
|
||
}
|
||
|
||
function clearGateAttempts(key) {
|
||
gateAttempts.delete(key);
|
||
}
|
||
|
||
export async function gateAuthHandler(req, res) {
|
||
const clientKey = gateClientKey(req);
|
||
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)) {
|
||
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 || {};
|
||
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" });
|
||
res.cookie(ROLE_COOKIE_NAME, role, { path: "/", secure: true, sameSite: "lax" });
|
||
return res.json({ ok: true, next });
|
||
}
|
||
|
||
export function gateMiddleware(req, res, next) {
|
||
const pfad = req.path;
|
||
|
||
const codesKonfiguriert = !!(process.env.SITE_ACCESS_SECRET && (process.env.SITE_ACCESS_CODE_DOGI || process.env.SITE_ACCESS_CODE_VANVAN));
|
||
if (!codesKonfiguriert) return next();
|
||
|
||
if (pfad === "/gate-auth" && req.method === "POST") {
|
||
// .catch(next) ergänzt 05.08.2026: ohne das würde ein Fehler beim Signieren zu einer
|
||
// unbehandelten Promise-Ablehnung und damit zum Absturz des ganzen Servers führen.
|
||
return Promise.resolve(gateAuthHandler(req, res)).catch(next);
|
||
}
|
||
|
||
const sessionToken = req.cookies?.[COOKIE_NAME];
|
||
|
||
(async () => {
|
||
const payload = sessionToken ? await verifySession(sessionToken, process.env.SITE_ACCESS_SECRET) : null;
|
||
if (payload) return next();
|
||
|
||
if (
|
||
pfad === "/gate.html" ||
|
||
pfad.startsWith("/assets/") ||
|
||
pfad === "/manifest.json" ||
|
||
pfad === "/sw.js" ||
|
||
pfad === "/favicon.ico"
|
||
) {
|
||
return next();
|
||
}
|
||
|
||
const nextParam = pfad === "/" ? "/index.html" : pfad + (req.originalUrl.includes("?") ? "?" + req.originalUrl.split("?")[1] : "");
|
||
return res.redirect(302, `/gate.html?next=${encodeURIComponent(nextParam)}`);
|
||
})().catch(next); // siehe oben: verhindert Serverabsturz bei einem Fehler in der Prüfung
|
||
}
|