Webdesign-Bereich: Fundament, oeffentliche Seiten, Zugangsschutz, PayPal
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]>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/* =====================================================================
|
||||
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);
|
||||
Reference in New Issue
Block a user