Files
dogfather-universe/server/pruef-modi-verborgen.mjs
T
DogFatherGitandClaude Opus 5 7be488e0ca Ein Zugang, den ausser DogFather niemand bemerkt
Aus dem Anforderungsdokument (Master-Blueprint V3.0) fehlte die Rolle
"Modi" ganz -- es gab nur spicy, admin, manager, scout, creator. Filipes
Bedingung dazu: "dass die keine neue eingangs kachel bekommen wie spicy
dogfather und so sondern einfach einen code. damit die von der workspace
auch nicht mal sehen dass die modis von mir einen eigenen zugang haben."

DER EINGANG. Es gibt keine sechste Kachel und es laesst sich auch keine
erzwingen: Wer von aussen `rolle: "modi"` schickt, bekommt wortgleich
dieselbe Antwort wie bei einer erfundenen Rolle. Ein Modi tippt auf
irgendeine vorhandene Kachel -- welche, ist gleichgueltig -- und gibt
seinen Code ein. Der Code allein entscheidet.

Moeglich macht das eine neue Spalte `code_kennung`: ein HMAC ueber den
Code, in Mikrosekunden nachgeschlagen. Zwei naheliegende Wege wurden
verworfen, weil man sie finden kann: ein Merkmal im Code ("M-...") waere
ein sichtbares Kennzeichen auf dem Zettel des Modis, eine eigene Adresse
(/modi.html) eine Seite, die man aufrufen kann. Der Suchschluessel steht
VOR dem gewohnten Weg, nicht dahinter -- ein Rueckfall nach einem
Fehlversuch haette genau die Fehlversuche verlaengert, und daran waere
es zu erkennen gewesen.

Unbedenklich, weil nachgemessen: Ein Code hat 16 Zeichen aus einem
32er-Alphabet, also 80 Bit Zufall. Der Suchschluessel sagt ausserdem nur,
WEN man pruefen soll -- ob der Code stimmt, sagt weiterhin scrypt.

DIE UNSICHTBARKEIT sitzt in verborgeneIds(), also an derselben einen
Stelle wie die Regel fuer den zweiten Admin-Zugang, und nicht in den
rund 170 Abfragen, die Personen lesen. Sie haengt dabei an der ROLLE und
nicht an einer Nummer -- die Schwachstelle, die im Kommentar der alten
Regel offen dasteht (wird Zugang 1 geloescht, rueckt der naechste nach),
kann einer Rolle nicht passieren.

Nach Filipes Entscheidungen: Die Modis sehen sich untereinander (Kapitel
7.2 des Dokuments), VanVan sieht sie mit (Kapitel 3, sie traegt dieselbe
Rolle), Codes gibt Filipe selbst weiter -- kein Einladelink, der in einem
Verlauf landen kann.

ZWEIMAL WAERE DAS WORT "MODI" BEINAHE IN EINER DATEI GELANDET, DIE JEDER
BEKOMMT: in den Rollenlisten von start.js und personen.js. Ein Blick in
den Quelltext haette genuegt. Der Anzeigename kommt jetzt aus
/workspace/api/ich (beschreibt immer nur den Angemeldeten selbst), die
Rollenauswahl aus der Antwort des Servers und nur an die DogFather-Rolle.

GEPRUEFT mit pruef-modi-verborgen.mjs (45 Pruefungen): fuenf Kacheln
fuehren mit dem Modi-Code hinein, ein Manager-Code auf fremder Kachel
weiterhin nicht (sonst waere nebenbei die Rollenpruefung abgeschafft),
und ueber fuenf Schnittstellen sieht ausser DogFather, VanVan und den
Modis niemand etwas -- auch nicht die ZAHL daneben.

Die Gegenprobe steht bewusst ganz unten, weil sie eine Person absichtlich
aus der Regel aushaengt: Weiter oben haette sie jeden Abschnitt danach
verfaelscht. Beim ersten Anlauf stand sie in der Mitte, und prompt tauchte
die Person in einer spaeteren Managerliste auf.

Abschnitt 5 prueft den Weg durch die Anwendung selbst (DogFather legt an,
der Modi meldet sich an). Die Abschnitte davor tragen die Personen von
Hand ein und rechnen den Suchschluessel selbst aus -- damit waere NICHT
bewiesen, dass personAnlegen() ihn im Betrieb schreibt. Ohne ihn kaeme
kein einziger echter Modi herein, und oben waere trotzdem alles gruen.

Die Umstellung der Rollenliste in der Datenbank steht ab jetzt einmal in
rollenRegelUmstellen() statt zum dritten Mal abgeschrieben. Jede Abschrift
waere eine Gelegenheit, eine der vier Absicherungen zu vergessen: Sicherung
vorher, Zaehlung innerhalb der Transaktion, Spaltenliste aus der Tabelle,
Pruefung auf verwaiste Verweise danach.

pruef-personen-formular erwartet jetzt sechs Rollen statt fuenf und prueft
die Liste statt nur die Anzahl -- sechs Karten koennten auch fuenf richtige
und eine doppelte sein. Dass die sechste dort auftaucht, ist gleichzeitig
der Nachweis, dass der Weg ueber den Server funktioniert.

Unveraendert bestanden: pruef-spicy (60), pruef-rollen (97),
pruef-verborgen, pruef-personen-liste, pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 00:11:39 +02:00

327 lines
15 KiB
JavaScript

/* =====================================================================
DER VERBORGENE MODI-ZUGANG
Zwei Fragen, und sie sind nicht dieselbe:
1. KOMMT EIN MODI HEREIN, ohne dass es dafuer eine Kachel gibt?
2. MERKT SONST JEMAND ETWAS DAVON?
Die zweite ist die schwierigere. Eine Liste zu filtern ist leicht;
uebersehen wird die ZAHL daneben, die weiter alles mitzaehlt -- und
eine Personenzahl, die nicht zur Liste passt, verraet genauso viel
wie ein Name.
MIT GEGENPROBE. Ganz unten wird die Regel absichtlich ausgehebelt
(ein Modi bekommt in der Datenbank eine andere Rolle), und dann MUSS
der Manager ihn sehen. Ohne diesen Teil waere nicht bewiesen, dass
die Pruefung ueberhaupt anschlagen kann -- eine Pruefung, die immer
bestaetigt, bestaetigt nichts.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-modi-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4359";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
/* NACH index.js: Der Server haengt dort zwei Auffangnetze ein, die
einen Absturz nur protokollieren. Fuer den Betrieb richtig, fuer eine
Pruefung fatal -- ohne diese Zeile bliebe der Prozess ewig stehen. */
notbremse(180_000, "pruef-modi-verborgen");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4359";
let fehler = 0;
let geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* Der dritte Ausgang: Konnte nicht nachgesehen werden. Kein Befund,
aber auch keine Bestaetigung -- und es wird gesagt, damit niemand
glaubt, hier sei etwas geprueft worden. */
let offen = 0;
const unklar = (t) => { offen++; console.log(" -- " + t); };
/* ---------- Personen anlegen ---------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
/* Der Suchschluessel muss VOR dem Anlegen dastehen -- die Personen
werden hier von Hand eingetragen, nicht ueber personAnlegen(). */
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
const kennung = (code) => createHmac("sha256", schluessel).update(code).digest("hex");
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, kennung(code), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idVan = anlegen("VanVan", "admin", "CODE-VAN-0001");
const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001");
const idMan = anlegen("Schulle", "manager", "CODE-MAN-0001");
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001");
const idModi1 = anlegen("Marina", "modi", "CODE-MODI-0001");
const idModi2 = anlegen("Kim", "modi", "CODE-MODI-0002");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idCreat, idScout, jetzt);
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)")
.run(idScout, idMan, jetzt);
d.close();
const MODIS = [idModi1, idModi2];
void idSpicy;
/* ---------- Anmelden ueber die Schnittstelle ------------------------- */
async function anmelden(rolle, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }),
});
const daten = await a.json().catch(() => ({}));
const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
return { status: a.status, daten, keks };
}
console.log("\n=== 1. Der Eingang: eine Kachel gibt es nicht ===\n");
for (const kachel of ["creator", "scout", "manager", "spicy", "admin"]) {
const a = await anmelden(kachel, "CODE-MODI-0001");
ok(a.status === 200 && a.daten?.rolle === "modi",
`Kachel "${kachel}" + Modi-Code -> angemeldet als ${a.daten?.rolle || a.status}`);
}
/* Der Umkehrschluss, und er ist der wichtigere: Der stille Weg darf
NICHT dazu fuehren, dass auch alle anderen die Kachel ignorieren
duerfen. Ein Manager mit der Kachel "creator" muss weiterhin
scheitern -- sonst haette ich nebenbei die Rollenpruefung
abgeschafft, ohne es zu merken. */
{
const a = await anmelden("creator", "CODE-MAN-0001");
ok(a.status === 401, `Manager-Code auf der Creator-Kachel bleibt abgewiesen (${a.status})`);
}
/* Wortgleichheit: "modi" von aussen anzugeben darf nicht anders klingen
als eine erfundene Rolle. */
{
const m = await anmelden("modi", "FALSCH-FALSCH-1");
const x = await anmelden("gibtesnicht", "FALSCH-FALSCH-1");
ok(m.status === 401 && x.status === 401
&& JSON.stringify(m.daten) === JSON.stringify(x.daten),
"Rolle \"modi\" von aussen klingt wie eine erfundene "
+ `(${m.status} ${JSON.stringify(m.daten)} vs. ${x.status} ${JSON.stringify(x.daten)})`);
}
{
const a = await anmelden("creator", "CODE-MODI-FALSCH");
ok(a.status === 401, `ein falscher Modi-Code wird abgewiesen (${a.status})`);
}
/* ---------- 2. Die Unsichtbarkeit ------------------------------------ */
console.log("\n=== 2. Wer sieht die Modis? ===\n");
/* Jede Schnittstelle, die Personen ausgibt. Dieselbe Landkarte wie in
pruef-verborgen.mjs -- wer eine neue baut, traegt sie in BEIDE ein. */
const WEGE = [
["Chat-Partner", "/workspace/api/chat/partner", (x) => x.personen],
["Personenwahl", "/workspace/api/personen", (x) => x.personen],
["Personenverwaltung", "/workspace/api/verwaltung/personen", (x) => x.personen],
["Betreuer-Auswahl", "/workspace/api/verwaltung/personen", (x) => x.betreuer],
/* NACHGESEHEN, NICHT ANGENOMMEN: Die Segmente der Zentrale tragen
KEINE Nummer -- nur `{ name, rolle, stufe, heute, alt }`, und der
Name ist auf den Vornamen gekuerzt. Wer hier nur nach `id` sucht,
bekommt einen gruenen Haken, ohne etwas angesehen zu haben. Genau
deshalb vergleicht der Treffer unten BEIDES. */
["Zentrale (Team)", "/workspace/api/zentrale", (x) => x.segmente],
];
/* Die Namen der Modis -- fuer die Wege, die keine Nummern liefern. */
const MODI_NAMEN = ["Marina", "Kim"];
const LEUTE = [
["DogFather", "admin", "CODE-DOGI-0001", true],
["VanVan", "admin", "CODE-VAN-0001", true],
["Spicy Media", "spicy", "CODE-SPIC-0001", false],
["Manager", "manager", "CODE-MAN-0001", false],
["Scout", "scout", "CODE-SCOU-0001", false],
["Creator", "creator", "CODE-ANNA-0001", false],
["Modi Marina", "creator", "CODE-MODI-0001", true],
];
async function hole(keks, pfad) {
const a = await fetch(BASIS + pfad, { headers: { cookie: keks } });
if (!a.ok) return { status: a.status, daten: null };
return { status: 200, daten: await a.json().catch(() => null) };
}
for (const [name, kachel, code, darf] of LEUTE) {
const an = await anmelden(kachel, code);
if (an.status !== 200) { unklar(`${name}: Anmeldung misslang (${an.status})`); continue; }
for (const [was, pfad, ziehen] of WEGE) {
const { status, daten } = await hole(an.keks, pfad);
if (status !== 200) {
unklar(`${name.padEnd(12)} ${was.padEnd(20)} kein Zugriff (${status})`);
continue;
}
let liste = [];
try { liste = ziehen(daten) || []; } catch { liste = []; }
const drin = liste.filter((z) => z
&& (MODIS.includes(z.id ?? z.person_id)
|| MODI_NAMEN.includes(String(z.name || "").trim().split(/\s+/)[0])));
if (darf) {
ok(true, `${name.padEnd(12)} ${was.padEnd(20)} sieht ${drin.length} Modi(s)`);
} else {
ok(drin.length === 0,
`${name.padEnd(12)} ${was.padEnd(20)} `
+ (drin.length ? `SIEHT ${drin.length} MODI -- FEHLER` : "sieht keinen Modi"));
}
}
}
/* ---------- 3. Die Zahl daneben -------------------------------------- */
console.log("\n=== 3. Auch die ZAHL darf sie nicht mitzaehlen ===\n");
{
const an = await anmelden("manager", "CODE-MAN-0001");
const { status, daten } = await hole(an.keks, "/workspace/api/personen");
if (status !== 200 || !Array.isArray(daten?.personen)) {
unklar(`Personenwahl fuer den Manager nicht lesbar (${status})`);
} else {
const namen = daten.personen.map((z) => z.name);
/* Die Zahl der geprueften Dinge gehoert in die BEDINGUNG, nicht nur
in den Meldetext: Eine leere Liste wuerde sonst als "keine Modis
drin" durchgehen, und die Pruefung waere wertlos. */
ok(namen.length > 0 && !namen.includes("Marina") && !namen.includes("Kim"),
`der Manager sieht ${namen.length} Personen, keine davon ein Modi `
+ `(${namen.join(", ")})`);
}
}
/* ---------- 4. Sehen die Modis einander? ----------------------------- */
console.log("\n=== 4. Die Modis sind untereinander ein Team ===\n");
{
const an = await anmelden("creator", "CODE-MODI-0001");
const { status, daten } = await hole(an.keks, "/workspace/api/personen");
if (status !== 200 || !Array.isArray(daten?.personen)) {
unklar(`Personenwahl fuer den Modi nicht lesbar (${status})`);
} else {
const ids = daten.personen.map((z) => z.id);
ok(ids.includes(idModi2), "Marina sieht Kim");
ok(ids.includes(idDogi), "Marina sieht DogFather");
/* VanVan bleibt fuer alle ausser DogFather verborgen -- das ist die
Regel vom 09.09.2026, und sie gilt auch gegenueber Modis. */
ok(!ids.includes(idVan), "Marina sieht VanVan NICHT (verborgener Zugang)");
ok(!ids.includes(idMan) && !ids.includes(idScout) && !ids.includes(idCreat),
"Marina sieht weder Manager noch Scout noch Creator");
}
}
/* ---------- 5. Der echte Weg durch die Anwendung --------------------- */
console.log("\n=== 5. Anlegen wie im Betrieb: DogFather legt einen Modi an ===\n");
{
const an = await anmelden("admin", "CODE-DOGI-0001");
const kopf = { "Content-Type": "application/json", cookie: an.keks };
/* a) Steht die Rolle ueberhaupt zur Auswahl -- und kommt sie vom
Server, nicht aus einer Datei im Browser? */
const { status, daten } = await hole(an.keks, "/workspace/api/verwaltung/personen");
if (status !== 200) {
unklar(`Verwaltung fuer DogFather nicht lesbar (${status})`);
} else {
const zusatz = daten?.zusatzrollen || [];
ok(zusatz.some((z) => z.wert === "modi"),
"DogFather bekommt die Rolle zur Auswahl angeboten "
+ `(${zusatz.map((z) => z.wert).join(", ") || "keine"})`);
}
/* b) Anlegen -- ueber genau den Weg, den die Oberflaeche benutzt. */
const a = await fetch(BASIS + "/workspace/api/verwaltung/personen", {
method: "POST", headers: kopf,
body: JSON.stringify({ name: "Testmodi Neu", rolle: "modi" }),
});
const neuDaten = await a.json().catch(() => ({}));
ok(a.status === 201 && neuDaten?.rolle === "modi" && typeof neuDaten?.code === "string",
`angelegt (${a.status}, Rolle ${neuDaten?.rolle}, `
+ `Code ${neuDaten?.code ? "erhalten" : "FEHLT"})`);
/* c) DER EIGENTLICHE PUNKT: Kommt der so angelegte Modi auch herein?
Die Abschnitte oben haben die Personen VON HAND eingetragen und
den Suchschluessel selbst ausgerechnet. Damit ist nicht bewiesen,
dass personAnlegen() ihn im Betrieb auch wirklich schreibt -- und
ohne ihn findet der stille Zugang niemanden. Waere die Zeile dort
vergessen worden, waeren oben trotzdem alle Haken gruen und kein
einziger echter Modi kaeme herein. */
if (neuDaten?.code) {
const b = await anmelden("creator", neuDaten.code);
ok(b.status === 200 && b.daten?.rolle === "modi",
"und meldet sich mit seinem Code an, ueber eine fremde Kachel "
+ `(${b.status}, ${b.daten?.rolle})`);
} else {
unklar("ohne Code laesst sich die Anmeldung nicht pruefen");
}
/* d) Und ist auch der Neue fuer den Manager unsichtbar? */
const m = await anmelden("manager", "CODE-MAN-0001");
const sicht = await hole(m.keks, "/workspace/api/personen");
if (sicht.status !== 200 || !Array.isArray(sicht.daten?.personen)) {
unklar(`Personenwahl fuer den Manager nicht lesbar (${sicht.status})`);
} else {
const namen = sicht.daten.personen.map((z) => z.name);
ok(namen.length > 0 && !namen.includes("Testmodi Neu"),
`der Manager sieht ihn nicht (${namen.length} Personen: ${namen.join(", ")})`);
}
}
/* ---------- 6. Die Gegenprobe ----------------------------------------
Sie steht ABSICHTLICH GANZ UNTEN: Sie haengt eine Person
absichtlich aus der Regel aus. Lief sie weiter oben, tauchte
diese Person in jeder Liste DANACH auf -- und jeder spaetere
Abschnitt maesse einen Zustand, den es im Betrieb nicht gibt.
-------------------------------------------------------------------- */
console.log("\n=== 6. Gegenprobe: kann diese Pruefung ueberhaupt anschlagen? ===\n");
{
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
d2.prepare("UPDATE personen SET rolle = 'creator' WHERE id = ?").run(idModi1);
/* Betreuung dazu, sonst waere der Creator fuer den Manager auch ohne
die Verbergen-Regel unsichtbar -- und die Gegenprobe pruefte nichts. */
d2.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idModi1, idScout, jetzt);
d2.close();
const an = await anmelden("manager", "CODE-MAN-0001");
const { status, daten } = await hole(an.keks, "/workspace/api/personen");
if (status !== 200 || !Array.isArray(daten?.personen)) {
unklar(`Gegenprobe nicht durchfuehrbar (${status})`);
} else {
const sichtbar = daten.personen.some((z) => z.id === idModi1);
ok(sichtbar, sichtbar
? "ohne die Rolle 'modi' sieht der Manager dieselbe Person sehr wohl "
+ "-- die Pruefung oben kann also anschlagen"
: "GEGENPROBE FEHLGESCHLAGEN: auch ohne die Regel bleibt die Person "
+ "unsichtbar. Die Pruefung oben beweist damit nichts.");
}
}
console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- `
+ `${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);