Umsetzung des "Website Masterplan" (16 Seiten) unter /webdesign. Zugangsschutz mit EIGENER Schranke (server/webdesign-gate.js) statt gate.js: gate.js laesst seit dem oeffentlichen Start am 21.08.2026 jeden durch, weil die Pruefung auf SITE_PUBLIC_LAUNCH_AT als allererste Zeile steht. Haette man /webdesign dahintergehaengt, waere der ausdruecklich nicht-oeffentliche Bereich inklusive Preisen und spaeteren Kundendaten ab der ersten Sekunde fuer jeden lesbar gewesen. Eigenes Sitzungs-Cookie, bereich="webdesign" im Token, damit ein gueltiges Universe-Cookie hier NICHT gilt. 37/37 Tests. Sieben oeffentliche Seiten in fuenf Sprachen (de, de-CH mit echtem Dialekt, en, fr, pt). Preise, Zeitrahmen, Paketnamen und die 30-%-Regel stehen an genau EINER Stelle in wd-core.js -- der Masterplan verlangt "ueberall widerspruchsfrei", und vier Kopien laufen bei der ersten Preisaenderung auseinander. Als App installierbar auf Handy und PC. Der Service Worker speichert bewusst KEINE HTML-Seite zwischen: nach dem Abmelden wuerden sonst geschuetzte Seiten weiter ausgeliefert, ohne dass der Server je gefragt wird. 18/18 Tests. Handy-Abnahme ueber alle Seiten in fuenf Breiten (320-1440) und fuenf Sprachen: 40/40. Der Test fand 35 echte Fehler (Touch-Ziele unter 44px), behoben im Designsystem statt einzeln pro Seite. PayPal (Wunsch 22.08.2026 "sofort auf meinem paypal"): Orders API mit intent=CAPTURE, also sofortiger Einzug statt blosser Reservierung. Gebuehr und Nettobetrag getrennt gespeichert. Betraege durchgehend als Ganzzahl in Cent. Gefaelschte Webhooks werden abgewiesen. Fail closed solange Zugangsdaten fehlen. 28/28 Tests gegen einen nachgebauten PayPal-Server. Datenbank: 15 Tabellen mit Praefix wd_, fachlich vollstaendig vom Universe getrennt. Co-Authored-By: Claude Opus 5 <[email protected]>
239 lines
10 KiB
JavaScript
239 lines
10 KiB
JavaScript
/* =====================================================================
|
|
test-webdesign-gate.mjs — Selbsttest der Webdesign-Zugangsschranke.
|
|
|
|
Ausführen (aus dem Ordner server/):
|
|
node test-webdesign-gate.mjs
|
|
|
|
Startet einen eigenen Server auf einem freien Port mit Test-Codes und
|
|
spielt die sicherheitskritischen Fälle durch. Rührt weder die echte
|
|
.env noch den laufenden Dienst an.
|
|
|
|
Warum ein eigener Test und nicht "einmal im Browser klicken":
|
|
Die gefährlichen Fälle sind genau die, die man beim Klicken NICHT sieht —
|
|
ein Universe-Cookie, das hier akzeptiert wird; ein "next", das auf eine
|
|
fremde Domain zeigt; eine Sperre, die nicht greift.
|
|
===================================================================== */
|
|
|
|
process.env.SITE_ACCESS_SECRET = "test-geheimnis-nur-fuer-diesen-lauf";
|
|
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
|
|
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
|
|
process.env.WEBDESIGN_STATUS = "vorabpruefung";
|
|
// Wichtig für den Kernfall: das Universe ist öffentlich, /webdesign nicht.
|
|
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
|
|
|
const { default: express } = await import("express");
|
|
const { default: cookieParser } = await import("cookie-parser");
|
|
const { webdesignGate, wdSigniere, wdPruefe } = await import("./webdesign-gate.js");
|
|
const { signSession } = await import("./gate.js");
|
|
|
|
const app = express();
|
|
app.set("trust proxy", 1);
|
|
app.use(cookieParser());
|
|
app.use(express.json());
|
|
app.use(webdesignGate);
|
|
// Ersatz für express.static — antwortet nur, wenn die Schranke durchlässt.
|
|
app.use((req, res) => res.status(200).send("GEHEIMER INHALT"));
|
|
|
|
const server = app.listen(0, "127.0.0.1");
|
|
await new Promise((r) => server.once("listening", r));
|
|
const basis = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
let bestanden = 0;
|
|
let gescheitert = 0;
|
|
|
|
function pruefe(name, bedingung, zusatz = "") {
|
|
if (bedingung) {
|
|
bestanden++;
|
|
console.log(` ok ${name}`);
|
|
} else {
|
|
gescheitert++;
|
|
console.log(` FEHLT ${name} ${zusatz}`);
|
|
}
|
|
}
|
|
|
|
async function hole(pfad, optionen = {}) {
|
|
const res = await fetch(basis + pfad, { redirect: "manual", ...optionen });
|
|
const text = await res.text();
|
|
let json = null;
|
|
try { json = JSON.parse(text); } catch { /* kein JSON, egal */ }
|
|
return { res, text, json, cookies: res.headers.getSetCookie?.() || [] };
|
|
}
|
|
|
|
function cookieAus(cookies, name) {
|
|
const treffer = cookies.find((c) => c.startsWith(name + "="));
|
|
return treffer ? treffer.split(";")[0].split("=").slice(1).join("=") : null;
|
|
}
|
|
|
|
console.log("\n=== Webdesign-Zugangsschranke ===\n");
|
|
|
|
/* --- 1. Der Kernfall: Universe ist öffentlich, /webdesign trotzdem dicht --- */
|
|
console.log("1. Grundschutz");
|
|
{
|
|
const a = await hole("/webdesign/");
|
|
pruefe("ohne Code -> Umleitung zur Zugangswand", a.res.status === 302, `(war ${a.res.status})`);
|
|
pruefe("Umleitungsziel ist die Zugangswand",
|
|
(a.res.headers.get("location") || "").startsWith("/webdesign/zugang.html"));
|
|
pruefe("Inhalt wird NICHT mitgeschickt", !a.text.includes("GEHEIMER INHALT"));
|
|
|
|
const b = await hole("/webdesign/preise.html");
|
|
pruefe("auch Unterseiten sind dicht", b.res.status === 302);
|
|
|
|
const c = await hole("/webdesign/zugang.html");
|
|
pruefe("Zugangswand selbst ist erreichbar", c.res.status === 200);
|
|
|
|
const d = await hole("/index.html");
|
|
pruefe("Universe bleibt unberührt öffentlich", d.res.status === 200);
|
|
|
|
const e = await hole("/webdesign/");
|
|
pruefe("noindex-Header ist gesetzt",
|
|
(e.res.headers.get("x-robots-tag") || "").includes("noindex"));
|
|
}
|
|
|
|
/* --- 2. Anmeldung --- */
|
|
console.log("\n2. Anmeldung");
|
|
let sitzungDogi = null;
|
|
{
|
|
const falsch = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code: "irgendwas" }),
|
|
});
|
|
pruefe("falscher Code -> 401", falsch.res.status === 401);
|
|
|
|
const richtig = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code: "DOGI-TEST-1" }),
|
|
});
|
|
pruefe("richtiger Code -> ok", richtig.res.status === 200 && richtig.json?.ok === true);
|
|
pruefe("Rolle wird gemeldet", richtig.json?.rolle === "dogfather");
|
|
sitzungDogi = cookieAus(richtig.cookies, "dogiwd_session");
|
|
pruefe("Sitzungs-Cookie wird gesetzt", !!sitzungDogi);
|
|
pruefe("Cookie ist httpOnly", richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && /httponly/i.test(c)));
|
|
pruefe("Cookie ist secure", richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && /secure/i.test(c)));
|
|
pruefe("Cookie hat KEIN Ablaufdatum (nur Browser-Sitzung)",
|
|
richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && !/max-age|expires/i.test(c)));
|
|
|
|
const vanvan = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code: "VANVAN-TEST-1" }),
|
|
});
|
|
pruefe("VanVans Code funktioniert gleichwertig", vanvan.json?.rolle === "vanvan");
|
|
|
|
const mit = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${sitzungDogi}` } });
|
|
pruefe("mit gültiger Sitzung -> Inhalt kommt", mit.res.status === 200 && mit.text.includes("GEHEIMER INHALT"));
|
|
}
|
|
|
|
/* --- 3. Weiterleitungsziel kann nicht auf eine fremde Seite zeigen --- */
|
|
console.log("\n3. Weiterleitungsziel");
|
|
{
|
|
const faelle = [
|
|
["//boese-seite.de", "protokoll-relative fremde Domain"],
|
|
["https://boese-seite.de", "vollstaendige fremde URL"],
|
|
["/webdesign/../../etc/passwd", "Pfadwechsel nach oben"],
|
|
["/index.html", "ausserhalb des Bereichs"],
|
|
["/webdesign\\boese", "Rueckwaertsschraegstrich"],
|
|
];
|
|
for (const [wert, was] of faelle) {
|
|
const a = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code: "DOGI-TEST-1", next: wert }),
|
|
});
|
|
pruefe(`abgewehrt: ${was}`, a.json?.next === "/webdesign/", `(wurde ${a.json?.next})`);
|
|
}
|
|
const gut = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code: "DOGI-TEST-1", next: "/webdesign/preise.html" }),
|
|
});
|
|
pruefe("eigener Pfad wird uebernommen", gut.json?.next === "/webdesign/preise.html");
|
|
}
|
|
|
|
/* --- 4. Ein Universe-Cookie darf hier NICHT gelten --- */
|
|
console.log("\n4. Trennung vom Universe-Zugang");
|
|
{
|
|
const universeToken = await signSession("dogi", process.env.SITE_ACCESS_SECRET);
|
|
const a = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${universeToken}` } });
|
|
pruefe("Universe-Sitzung oeffnet den Webdesign-Bereich NICHT", a.res.status === 302);
|
|
|
|
pruefe("wdPruefe lehnt Universe-Token ab", wdPruefe(universeToken, process.env.SITE_ACCESS_SECRET) === null);
|
|
|
|
const fremd = wdSigniere("dogfather", "ein-ganz-anderes-geheimnis");
|
|
const b = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${fremd}` } });
|
|
pruefe("mit falschem Geheimnis signiert -> abgelehnt", b.res.status === 302);
|
|
|
|
const verdreht = sitzungDogi.split(".")[0] + "." + "AAAA";
|
|
const c = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${verdreht}` } });
|
|
pruefe("verfaelschte Signatur -> abgelehnt", c.res.status === 302);
|
|
|
|
const abgelaufen = (() => {
|
|
const inhalt = Buffer.from(JSON.stringify({ bereich: "webdesign", rolle: "dogfather", exp: Date.now() - 1000 }))
|
|
.toString("base64url");
|
|
// absichtlich ohne gueltige Signatur -- muss doppelt scheitern
|
|
return inhalt + ".xxx";
|
|
})();
|
|
const d = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${abgelaufen}` } });
|
|
pruefe("abgelaufene/ungueltige Sitzung -> abgelehnt", d.res.status === 302);
|
|
}
|
|
|
|
/* --- 5. Fehlversuchs-Sperre --- */
|
|
console.log("\n5. Fehlversuchs-Sperre");
|
|
{
|
|
let letzte = null;
|
|
for (let i = 0; i < 6; i++) {
|
|
letzte = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code: "falsch-" + i }),
|
|
});
|
|
}
|
|
pruefe("nach fuenf Fehlversuchen greift die Sperre (429)", letzte.res.status === 429, `(war ${letzte.res.status})`);
|
|
|
|
const trotzRichtig = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code: "DOGI-TEST-1" }),
|
|
});
|
|
pruefe("Sperre gilt auch fuer den richtigen Code", trotzRichtig.res.status === 429);
|
|
pruefe("Meldung nennt die Wartezeit", /Minute/.test(trotzRichtig.json?.error || ""));
|
|
|
|
const andere = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": "203.0.113.77" },
|
|
body: JSON.stringify({ code: "DOGI-TEST-1" }),
|
|
});
|
|
pruefe("andere Besucher sind NICHT mitgesperrt", andere.res.status === 200,
|
|
"(genau dieser Fehler trat am 19.08.2026 im Universe auf)");
|
|
}
|
|
|
|
/* --- 6. Statusmodi --- */
|
|
console.log("\n6. Zugangsstatus");
|
|
{
|
|
process.env.WEBDESIGN_STATUS = "entwurf";
|
|
const a = await hole("/webdesign/zugang-pruefen", {
|
|
method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "203.0.113.90" },
|
|
body: JSON.stringify({ code: "DOGI-TEST-1" }),
|
|
});
|
|
pruefe("ENTWURF: auch der richtige Code oeffnet nicht", a.res.status === 403);
|
|
|
|
process.env.WEBDESIGN_STATUS = "freigegeben";
|
|
const b = await hole("/webdesign/preise.html");
|
|
pruefe("FREIGEGEBEN: oeffentliche Seite ohne Code erreichbar", b.res.status === 200);
|
|
const c = await hole("/webdesign/verwaltung.html");
|
|
pruefe("FREIGEGEBEN: Verwaltung bleibt trotzdem geschuetzt", c.res.status === 302);
|
|
const d = await hole("/webdesign/preise.html");
|
|
pruefe("FREIGEGEBEN: kein noindex mehr", !(d.res.headers.get("x-robots-tag") || "").includes("noindex"));
|
|
|
|
process.env.WEBDESIGN_STATUS = "vorabpruefung";
|
|
}
|
|
|
|
/* --- 7. Ohne Geheimnis muss zugesperrt werden, nicht aufgesperrt --- */
|
|
console.log("\n7. Fehlende Einstellung");
|
|
{
|
|
const gemerkt = process.env.SITE_ACCESS_SECRET;
|
|
delete process.env.SITE_ACCESS_SECRET;
|
|
const a = await hole("/webdesign/");
|
|
pruefe("ohne Geheimnis -> 503 statt offener Tuer", a.res.status === 503, `(war ${a.res.status})`);
|
|
pruefe("Inhalt bleibt geheim", !a.text.includes("GEHEIMER INHALT"));
|
|
process.env.SITE_ACCESS_SECRET = gemerkt;
|
|
}
|
|
|
|
server.close();
|
|
console.log(`\n=== ${bestanden} bestanden, ${gescheitert} gescheitert ===\n`);
|
|
process.exit(gescheitert === 0 ? 0 : 1);
|