From ae1886c03009bbb6d1b0e8e2f53a9651f51cc6d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:57:28 +0000 Subject: [PATCH] Terminplaner: Zaehler je IP getrennt und Abgelaufene zuerst, Notantwort beim Schreiben, Pruefung aller Antworten auf interne IDs Co-Authored-By: Claude Sonnet 5.5 --- server/routes/publicTermine.js | 69 ++++++++++++++++++++++------ server/services/termine/limits.js | 62 ++++++++++++++++++++------ test/publicTermine.test.js | 74 +++++++++++++++++++++++++++++-- test/termineLimits.test.js | 52 ++++++++++++++++++++++ 4 files changed, 226 insertions(+), 31 deletions(-) diff --git a/server/routes/publicTermine.js b/server/routes/publicTermine.js index 3d19f32..96a0ec7 100644 --- a/server/routes/publicTermine.js +++ b/server/routes/publicTermine.js @@ -143,6 +143,43 @@ function schreibe(req, res, ergebnis, cors = { erlaubt: null }) { return res.end(inhalt) } +/** + * Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert (z. B. nicht serialisierbarer Wert): ein schlichtes 503 + * mit fester Meldung, sofern noch nichts gesendet wurde; sonst wird die Antwort beendet. Eine Anfrage bleibt nie haengen und + * der async Handler lehnt nie unbehandelt ab (Express 4 faengt das nicht). + */ +function notantwort(res, err) { + try { + console.error(`[publicTermine] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + } catch { + // das Log darf die Notantwort nicht verhindern + } + try { + if (res.headersSent) return void res.end() + const inhalt = JSON.stringify(NICHT_ERREICHBAR) + res.statusCode = 503 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII + return void res.end(inhalt) + } catch { + try { + res.destroy() + } catch { + // nichts mehr zu tun + } + } +} + +/** schreibe, aber mit der Notantwort statt eines Fehlers (fuer Stellen ausserhalb von route). */ +function schreibeSicher(req, res, ergebnis, cors) { + try { + schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + /** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Funktionen (GET kurz im Browser-Cache). */ const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' }) @@ -154,18 +191,22 @@ const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) * schneller als config.termine.minAntwortMs (ab Beginn der Anfrage, auch beim Lesen des Rumpfes). */ const route = (fn, { mindestdauer = false } = {}) => async (req, res) => { - const cors = { erlaubt: null } - let ergebnis try { - ergebnis = await fn(req, uhr(), cors) + const cors = { erlaubt: null } + let ergebnis + try { + ergebnis = await fn(req, uhr(), cors) + } catch (err) { + ergebnis = fehlerErgebnis(err) + } + if (mindestdauer) { + const rest = config.termine.minAntwortMs - (performance.now() - req.termineStart) + if (rest > 0) await warte(rest) + } + schreibe(req, res, ergebnis, cors) } catch (err) { - ergebnis = fehlerErgebnis(err) + notantwort(res, err) } - if (mindestdauer) { - const rest = config.termine.minAntwortMs - (performance.now() - req.termineStart) - if (rest > 0) await warte(rest) - } - schreibe(req, res, ergebnis, cors) } // ---------------------------------------------------------------- Herkunft @@ -210,7 +251,7 @@ router.use((req, res, next) => { // Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen). if (Number(req.get('content-length')) > BODY_MAX) { res.set('Connection', 'close') - return schreibe(req, res, { status: 413, body: ZU_GROSS }) + return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) } return next() }) @@ -530,13 +571,13 @@ router.options( // ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers -router.use((req, res) => schreibe(req, res, { status: 404, body: NICHT_GEFUNDEN })) +router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN })) // Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers router.use((err, req, res, _next) => { - if (err?.type === 'entity.too.large' || err?.status === 413) return schreibe(req, res, { status: 413, body: ZU_GROSS }) - if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibe(req, res, { status: 400, body: UNGUELTIG }) - return schreibe(req, res, fehlerErgebnis(err)) + if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) + if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG }) + return schreibeSicher(req, res, fehlerErgebnis(err)) }) export default router diff --git a/server/services/termine/limits.js b/server/services/termine/limits.js index 0f1627f..bda2b60 100644 --- a/server/services/termine/limits.js +++ b/server/services/termine/limits.js @@ -15,6 +15,12 @@ import { pruefeNow, pruefeText } from './util.js' * je IP und je siteKey), darfBuchungsversuch (je IP), darfHaltung (je siteKey), darfWiderrufsversuch (je IP) und darfG01 * (Bestaetigungsmails je Adresse und Funktion). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist * ein ganzes /64, die Adresse darin wechselt beliebig. + * + * Speicher: zwei getrennte Maps mit je hoechstens 10 000 Schluesseln. Die Zaehler je IP (Schluessel mit 'ip:') haben + * beliebig viele, von Angreifern frei waehlbare Schluessel; sie duerfen die langlebigen Zaehler je Adresse, Funktion + * und siteKey (G01 und G08 einen Tag, Haltungen eine Stunde) nie verdraengen. Ist eine Map voll, werden zuerst alle + * abgelaufenen Zaehler entfernt (jeder Eintrag merkt sich das Ende seines Fensters); erst wenn dann noch kein Platz ist, + * faellt der aelteste noch laufende Zaehler dieser Map weg. */ const MAX_SCHLUESSEL = 10000 @@ -41,15 +47,45 @@ const WIDERRUFE_JE_IP = 10 /** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */ const G01_JE_ADRESSE = 5 -/** schluessel -> { start (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. */ -const zaehler = new Map() +/** + * Eine Map schluessel -> { start, ende (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. + * fruehesterAblauf ist eine untere Schranke fuer das kleinste ende: solange nowMs darunter liegt, kann es nichts + * Abgelaufenes geben und das Aufraeumen einer vollen Map entfaellt (kein Durchlauf je neuem Schluessel). + */ +const neuerSpeicher = () => ({ map: new Map(), fruehesterAblauf: Infinity }) +const ipZaehler = neuerSpeicher() +const sonstigeZaehler = neuerSpeicher() + +/** Zaehler je IP ('ip:...') und alle anderen liegen in getrennten Maps. */ +const speicherFuer = (schluessel) => (schluessel.startsWith('ip:') ? ipZaehler : sonstigeZaehler) /** Der Zaehler des Schluessels, wenn sein Fenster zum Zeitpunkt nowMs noch laeuft; sonst undefined. */ function laufend(schluessel, fensterMs, nowMs) { - const eintrag = zaehler.get(schluessel) + const eintrag = speicherFuer(schluessel).map.get(schluessel) return eintrag && nowMs < eintrag.start + fensterMs ? eintrag : undefined } +/** Entfernt alle abgelaufenen Zaehler und merkt sich das fruehste Ende der uebrigen. */ +function raeumeAbgelaufene(speicher, nowMs) { + let fruehester = Infinity + for (const [schluessel, eintrag] of speicher.map) { + if (eintrag.ende <= nowMs) speicher.map.delete(schluessel) + else if (eintrag.ende < fruehester) fruehester = eintrag.ende + } + speicher.fruehesterAblauf = fruehester +} + +/** Beginnt ein neues Fenster fuer den Schluessel; macht in einer vollen Map zuerst Platz (abgelaufene, dann der aelteste). */ +function beginne(schluessel, fensterMs, nowMs) { + const speicher = speicherFuer(schluessel) + speicher.map.delete(schluessel) // neu einfuegen = ans Ende der Reihenfolge + if (speicher.map.size >= MAX_SCHLUESSEL && nowMs >= speicher.fruehesterAblauf) raeumeAbgelaufene(speicher, nowMs) + if (speicher.map.size >= MAX_SCHLUESSEL) speicher.map.delete(speicher.map.keys().next().value) + const ende = nowMs + fensterMs + speicher.map.set(schluessel, { start: nowMs, ende, n: 1 }) + if (ende < speicher.fruehesterAblauf) speicher.fruehesterAblauf = ende +} + /** * Zaehlt ein Ereignis je Regel, aber nur, wenn keine Regel ihre Grenze erreicht hat (alle oder keine). * @param {{ schluessel: string, max: number, fensterMs: number }[]} regeln @@ -66,13 +102,8 @@ export function zaehleAlle(regeln, now) { } for (const { schluessel, fensterMs } of regeln) { const eintrag = laufend(schluessel, fensterMs, nowMs) - if (eintrag) { - eintrag.n += 1 - continue - } - zaehler.delete(schluessel) // neu einfuegen = ans Ende der Reihenfolge - if (zaehler.size >= MAX_SCHLUESSEL) zaehler.delete(zaehler.keys().next().value) - zaehler.set(schluessel, { start: nowMs, n: 1 }) + if (eintrag) eintrag.n += 1 + else beginne(schluessel, fensterMs, nowMs) } return true } @@ -138,7 +169,7 @@ export function ipSchluessel(ip) { /** Darf diese IP (IPv6 je /64) jetzt einen lesenden Abruf oder Token-Aufruf machen? Hoechstens 120 je Minute. */ export function darfAnfrage(ip, now) { - return zaehle(`anfrage-ip:${ipSchluessel(ip)}`, ANFRAGEN_JE_IP, MINUTE_MS, now) + return zaehle(`ip:anfrage:${ipSchluessel(ip)}`, ANFRAGEN_JE_IP, MINUTE_MS, now) } /** Darf diese Funktion (siteKey) jetzt einen lesenden Abruf beantworten? Hoechstens 600 je Minute fuer alle zusammen. */ @@ -148,7 +179,7 @@ export function darfAnfrageSite(siteKey, now) { /** Darf diese IP (IPv6 je /64) jetzt ein Buchungsformular abschicken? Hoechstens 10 je 10 Minuten (jeder Versuch zaehlt). */ export function darfBuchungsversuch(ip, now) { - return zaehle(`buchung-ip:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now) + return zaehle(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now) } /** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */ @@ -158,7 +189,7 @@ export function darfHaltung(siteKey, now) { /** Darf diese IP (IPv6 je /64) jetzt eine Widerrufserklaerung abschicken? Hoechstens 10 je Stunde. */ export function darfWiderrufsversuch(ip, now) { - return zaehle(`widerruf-ip:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now) + return zaehle(`ip:widerruf:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now) } /** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */ @@ -168,5 +199,8 @@ export function darfG01(featureId, adresse, now) { /** Leert alle Zaehler (Tests). */ export function resetTermineLimitsForTests() { - zaehler.clear() + for (const speicher of [ipZaehler, sonstigeZaehler]) { + speicher.map.clear() + speicher.fruehesterAblauf = Infinity + } } diff --git a/test/publicTermine.test.js b/test/publicTermine.test.js index 2733d74..b11261c 100644 --- a/test/publicTermine.test.js +++ b/test/publicTermine.test.js @@ -127,9 +127,38 @@ let log let clientIp = 0 let ip -/** Anfrage an die oeffentliche Schnittstelle: Pfad ab /v1, Origin wie im Browser (Vorgabe: Seite des Betriebs), Client-Adresse je Test. */ -function api(method, pfad, { body, origin = O_LIVE, ip: adresse = ip, headers = {}, cookie } = {}) { - return send(method, BASIS + pfad, { body, cookie, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } }) +// Nichts Internes in irgendeiner Antwort: weder Felder noch Werte mit IDs des Portals oder Appwrite-Metadaten. +// Erlaubt sind die Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen); Buchungs-IDs sind UUIDs. +const VERBOTENE_FELDER = new Set([ + 'customerId', 'projectId', 'featureId', 'bookingId', '$id', '$collectionId', '$databaseId', '$createdAt', '$updatedAt', '$permissions', + 'verifyHash', 'manageHash', 'feedToken', +]) +const INTERNE_IDS = [KUNDE, PROJEKT, FEATURE] +const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i + +/** Wirft, wenn irgendwo im (JSON-)Wert ein verbotenes Feld, eine interne ID oder eine Buchungs-UUID steht. */ +function pruefeOhneInternes(wert, pfad = '$', { uuid = true } = {}) { + if (typeof wert === 'string') { + for (const id of INTERNE_IDS) assert.ok(!wert.includes(id), `${pfad}: interne ID ${id} in der Antwort`) + if (uuid) assert.ok(!UUID_RE.test(wert), `${pfad}: UUID (Buchungs-ID?) in der Antwort`) + } else if (Array.isArray(wert)) { + wert.forEach((w, i) => pruefeOhneInternes(w, `${pfad}[${i}]`, { uuid })) + } else if (wert !== null && typeof wert === 'object') { + for (const [feld, w] of Object.entries(wert)) { + assert.ok(!VERBOTENE_FELDER.has(feld), `${pfad}.${feld}: internes Feld in der Antwort`) + pruefeOhneInternes(w, `${pfad}.${feld}`, { uuid }) + } + } +} + +/** + * Anfrage an die oeffentliche Schnittstelle: Pfad ab /v1, Origin wie im Browser (Vorgabe: Seite des Betriebs), Client-Adresse je Test. + * JEDE Antwort (Erfolg wie Fehler) wird auf interne IDs geprueft; der Kalender-Text (UID = Buchungs-ID ist dort Pflicht) nur auf Portal-IDs. + */ +async function api(method, pfad, { body, origin = O_LIVE, ip: adresse = ip, headers = {}, cookie } = {}) { + const res = await send(method, BASIS + pfad, { body, cookie, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } }) + pruefeOhneInternes(res.body, `${method} ${pfad}`, { uuid: typeof res.body !== 'string' }) + return res } const buchen = (over, opts) => api('POST', `/${SITE_KEY}/buchungen`, { body: buchungBody(over), ...opts }) const tokenAnruf = (aktion, body, opts = {}) => api('POST', `/termin/${aktion}`, { body, origin: O_PORTAL, ...opts }) @@ -999,3 +1028,42 @@ test('Antworten tragen kein Set-Cookie und fuer Browser nosniff', async () => { assert.equal(res.headers.get('x-content-type-options'), 'nosniff') } }) + +test('der Pruefer fuer interne IDs hat Zaehne: Felder, Werte, verschachtelt, UUIDs - die Buchungsnummer ist erlaubt', () => { + assert.doesNotThrow(() => pruefeOhneInternes({ ok: true, termin: { nummer: 'AB12CD34', leistung: 'Damenschnitt' }, ressourcen: [{ id: R1 }] })) + for (const schlecht of [ + { featureId: 'x' }, { a: { b: [{ customerId: 'x' }] } }, { $id: 'x' }, { $collectionId: 'termineBookings' }, { text: `Funktion ${FEATURE}` }, + { liste: [KUNDE] }, { id: '0b2a3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d' }, { projectId: PROJEKT }, + ]) { + assert.throws(() => pruefeOhneInternes(schlecht), assert.AssertionError, JSON.stringify(schlecht)) + } +}) + +test('schreibt die Antwort selbst nicht (Serialisierung wirft): 503 { fehler } statt haengender Anfrage und unbehandelter Ablehnung', async () => { + await speichere() + const unbehandelt = [] + const merke = (grund) => unbehandelt.push(grund) + process.on('unhandledRejection', merke) + const original = Buffer.byteLength + const ersatz = mock.method(Buffer, 'byteLength', function byteLength(text, ...rest) { + if (typeof text === 'string' && text.includes('"formToken"')) throw new Error('Nachbau kaputt: geheimer Rohtext') + return original.call(this, text, ...rest) + }) + let res + try { + res = await Promise.race([ + api('GET', `/${SITE_KEY}/config`), + new Promise((_, ab) => setTimeout(() => ab(new Error('Anfrage haengt: keine Antwort')), 3000).unref()), + ]) + await new Promise((resolve) => setImmediate(resolve)) // eine unbehandelte Ablehnung meldet sich erst danach + } finally { + ersatz.mock.restore() + process.off('unhandledRejection', merke) + } + assert.deepEqual([res.status, res.body], [503, NICHT_ERREICHBAR]) + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.deepEqual(unbehandelt, []) + assert.ok(!JSON.stringify(res.body).includes('geheimer')) + assert.ok(log.error.mock.calls.some((c) => String(c.arguments[0]).includes('Antwort nicht geschrieben')), 'steht im Log') + assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200, 'danach geht es normal weiter') +}) diff --git a/test/termineLimits.test.js b/test/termineLimits.test.js index 0516da7..cee865c 100644 --- a/test/termineLimits.test.js +++ b/test/termineLimits.test.js @@ -167,3 +167,55 @@ test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren assert.equal(darfG01('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder') assert.equal(darfG08('f1', 'max@example.com', T0), true, 'G08 zaehlt getrennt von G01') }) + +// ---------------------------------------------------------------- Speicher: IP-Zaehler getrennt, Abgelaufene zuerst + +/** Eindeutige IPv4-Adresse Nr. i (10.a.b.c). */ +const ipNr = (i) => `10.${(i >> 16) & 255}.${(i >> 8) & 255}.${i & 255}` + +test('Speicher: 10 000 und mehr verschiedene IPs verdraengen keinen laufenden Zaehler je Adresse, Funktion oder siteKey', () => { + // G01 einer Adresse ausgeschoepft, eine Haltungsgrenze angebrochen + for (let i = 0; i < 5; i += 1) assert.equal(darfG01('f1', 'opfer@example.com', T0), true) + assert.equal(darfG01('f1', 'opfer@example.com', T0), false) + for (let i = 0; i < 60; i += 1) assert.equal(darfHaltung('abcdefghijklmnop', T0), true) + assert.equal(darfG08('f1', 'opfer@example.com', T0), true) + + // ueber 10 000 verschiedene Quelladressen: alle drei IP-Arten + for (let i = 0; i < 10500; i += 1) darfAnfrage(ipNr(i), T0) + for (let i = 0; i < 10500; i += 1) darfBuchungsversuch(ipNr(i), plus(1000)) + for (let i = 0; i < 10500; i += 1) darfWiderrufsversuch(ipNr(i), plus(2000)) + + assert.equal(darfG01('f1', 'opfer@example.com', plus(3000)), false, 'die G01-Grenze der Adresse gilt weiter') + assert.equal(darfHaltung('abcdefghijklmnop', plus(3000)), false, 'die Haltungsgrenze gilt weiter') + assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), true, 'G08 zaehlt weiter ab 1 (2 von 3 frei)') + assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), true) + assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), false, 'und die dritte war die letzte') +}) + +test('Speicher: eine volle Map raeumt zuerst Abgelaufene ab - ein laufender Zaehler bleibt, auch der aelteste', () => { + // der aelteste Zaehler laeuft eine Stunde; danach fuellen Minuten-Zaehler die IP-Map + for (let i = 0; i < 10; i += 1) assert.equal(darfWiderrufsversuch('203.0.113.7', T0), true) + assert.equal(darfWiderrufsversuch('203.0.113.7', T0), false) + for (let i = 0; i < 9999; i += 1) darfAnfrage(ipNr(i), T0) // Map voll (10 000 Schluessel) + // zwei Minuten spaeter sind alle Minuten-Zaehler abgelaufen: neue Schluessel schaffen Platz, ohne den laufenden zu verdraengen + for (let i = 0; i < 300; i += 1) darfAnfrage(ipNr(20000 + i), plus(2 * MINUTE)) + assert.equal(darfWiderrufsversuch('203.0.113.7', plus(2 * MINUTE)), false, 'der Stunden-Zaehler ueberlebt') + assert.equal(darfAnfrage(ipNr(5), plus(2 * MINUTE)), true, 'abgelaufene Zaehler zaehlen neu') + + // dasselbe in der Map fuer alle anderen Zaehler + resetTermineLimitsForTests() + assert.equal(zaehle('alt', 1, TAG, T0), true) + for (let i = 0; i < 9999; i += 1) zaehle(`k${i}`, 1, MINUTE, T0) + for (let i = 0; i < 300; i += 1) zaehle(`neu${i}`, 1, MINUTE, plus(2 * MINUTE)) + assert.equal(zaehle('alt', 1, TAG, plus(2 * MINUTE)), false, 'der Tages-Zaehler ueberlebt') +}) + +test('Speicher: sind alle Zaehler noch am Laufen, faellt wie bisher der aelteste weg (Obergrenze gilt je Map)', () => { + assert.equal(zaehle('aeltester', 1, TAG, T0), true) + for (let i = 0; i < 9999; i += 1) zaehle(`k${i}`, 1, TAG, plus(1000)) + for (let i = 0; i < 10000; i += 1) darfAnfrage(ipNr(i), plus(1000)) // die IP-Map ist eine andere + assert.equal(zaehle('aeltester', 1, TAG, plus(2000)), false, 'durch die IP-Zaehler nicht verdraengt') + zaehle('neu', 1, TAG, plus(3000)) // 10 001. Schluessel der Map: der aelteste faellt weg + assert.equal(zaehle('aeltester', 1, TAG, plus(4000)), true) + assert.equal(zaehle('neu', 1, TAG, plus(4000)), false) +})