Router /api/public/termine/v1 (config, tage, zeiten, buchungen, widerruf, Token-Seiten /termin/*, Kalender-Abo) mit eigenem 16-KB-Parser und Fehlermiddleware, CORS nach Herkunft, Honeypot und Formular-Token, Grenzen je IP (IPv6 je /64), siteKey und Adresse in limits.js, Mindestantwortzeit (TERMINE_MIN_ANTWORT_MS) und 10-Sekunden-Cache fuer tage/zeiten. Vor dem globalen express.json eingehaengt; /api/public/ ist vom Kundenansicht-Waechter ausgenommen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
173 lines
8.1 KiB
JavaScript
173 lines
8.1 KiB
JavaScript
import { isIPv4, isIPv6 } from 'node:net'
|
|
import { pruefeNow, pruefeText } from './util.js'
|
|
|
|
/**
|
|
* Grenzen des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 8): Zaehler im Speicher dieses
|
|
* Prozesses, je Schluessel ein festes Fenster ab dem ersten gezaehlten Ereignis. Die Schluessel enthalten Adressen oder
|
|
* IPs und verlassen den Speicher nie (kein Log, keine Datenbank). Hoechstens 10 000 Schluessel: ist die Map voll,
|
|
* faellt der aelteste (zuerst begonnene) Zaehler weg; ein abgelaufenes Fenster wird beim naechsten Zaehlen neu begonnen.
|
|
* Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht).
|
|
*
|
|
* Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt).
|
|
* Mails des Widerrufs: darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende
|
|
* Buchung, ein Treffer bekommt seine Mails immer.
|
|
* Oeffentliche Schnittstelle (routes/publicTermine.js): darfAnfrage / darfAnfrageSite (lesende Abrufe und Token-Aufrufe
|
|
* 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.
|
|
*/
|
|
|
|
const MAX_SCHLUESSEL = 10000
|
|
const STUNDE_MS = 60 * 60 * 1000
|
|
const TAG_MS = 24 * STUNDE_MS
|
|
|
|
/** G08 (Eingangsbestaetigung des Widerrufs): je Empfaengeradresse und Tag, je Funktion und Stunde. */
|
|
const G08_JE_ADRESSE = 3
|
|
const G08_JE_FUNKTION = 20
|
|
/** K21 "nicht zugeordnet": je Funktion und Stunde. */
|
|
const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 30
|
|
|
|
const MINUTE_MS = 60 * 1000
|
|
|
|
/** Lesende Abrufe und Aufrufe mit Token: je IP und Minute, je siteKey und Minute. */
|
|
const ANFRAGEN_JE_IP = 120
|
|
const ANFRAGEN_JE_SITE = 600
|
|
/** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */
|
|
const BUCHUNGEN_JE_IP = 10
|
|
const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS
|
|
const HALTUNGEN_JE_SITE = 60
|
|
/** Widerrufserklaerungen: je IP und Stunde. */
|
|
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()
|
|
|
|
/** Der Zaehler des Schluessels, wenn sein Fenster zum Zeitpunkt nowMs noch laeuft; sonst undefined. */
|
|
function laufend(schluessel, fensterMs, nowMs) {
|
|
const eintrag = zaehler.get(schluessel)
|
|
return eintrag && nowMs < eintrag.start + fensterMs ? eintrag : undefined
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @param {Date} now
|
|
* @returns {boolean} true = erlaubt (gezaehlt), false = mindestens eine Grenze erreicht
|
|
*/
|
|
export function zaehleAlle(regeln, now) {
|
|
const nowMs = pruefeNow(now, 'zaehleAlle')
|
|
if (!Array.isArray(regeln) || regeln.length === 0) throw new TypeError('zaehleAlle: regeln fehlen')
|
|
for (const { schluessel, max, fensterMs } of regeln) {
|
|
pruefeText(schluessel, 'schluessel')
|
|
if (!Number.isInteger(max) || max < 0 || !(fensterMs > 0)) throw new TypeError('zaehleAlle: max oder fensterMs ungueltig')
|
|
if ((laufend(schluessel, fensterMs, nowMs)?.n ?? 0) >= max) return false
|
|
}
|
|
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 })
|
|
}
|
|
return true
|
|
}
|
|
|
|
/** Ein Ereignis zaehlen: hoechstens max je Fenster. => true = erlaubt (gezaehlt), false = Grenze erreicht. */
|
|
export function zaehle(schluessel, max, fensterMs, now) {
|
|
return zaehleAlle([{ schluessel, max, fensterMs }], now)
|
|
}
|
|
|
|
/** Darf eine G08 an diese (normalisierte) Adresse gehen? Hoechstens 3 je Adresse und Tag, 20 je Funktion und Stunde. */
|
|
export function darfG08(featureId, adresse, now) {
|
|
return zaehleAlle(
|
|
[
|
|
{ schluessel: `g08-adresse:${adresse}`, max: G08_JE_ADRESSE, fensterMs: TAG_MS },
|
|
{ schluessel: `g08-funktion:${featureId}`, max: G08_JE_FUNKTION, fensterMs: STUNDE_MS },
|
|
],
|
|
now,
|
|
)
|
|
}
|
|
|
|
/** Darf eine K21 "nicht zugeordnet" an den Betrieb gehen? Hoechstens 30 je Funktion und Stunde. */
|
|
export function darfK21NichtZugeordnet(featureId, now) {
|
|
return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- IP, siteKey, G01
|
|
|
|
const acht = (gruppen) => gruppen.length === 8 && gruppen.every((g) => /^[0-9a-f]{1,4}$/.test(g))
|
|
|
|
/** Die acht Gruppen einer IPv6-Adresse (ohne Zonenkennung, Kurzform "::" und eingebettete IPv4 aufgeloest) oder null. */
|
|
function ipv6Gruppen(adresse) {
|
|
let s = adresse.split('%')[0]
|
|
const v4 = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(s)
|
|
if (v4) {
|
|
const [a, b, c, d] = v4.slice(1).map(Number)
|
|
s = `${s.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`
|
|
}
|
|
const teile = s.split('::')
|
|
if (teile.length > 2) return null
|
|
const links = teile[0] === '' ? [] : teile[0].split(':')
|
|
const rechts = teile.length === 2 && teile[1] !== '' ? teile[1].split(':') : []
|
|
const gruppen = teile.length === 2 ? [...links, ...Array(Math.max(0, 8 - links.length - rechts.length)).fill('0'), ...rechts] : links
|
|
return acht(gruppen) ? gruppen : null
|
|
}
|
|
|
|
/**
|
|
* Zaehlschluessel einer Client-Adresse (req.ip): IPv4 unveraendert, IPv4 in IPv6 (::ffff:a.b.c.d) als IPv4, IPv6 je /64
|
|
* (die ersten vier Gruppen, vierstellig); fehlt die Adresse oder ist sie unlesbar: 'unbekannt' (alle teilen sich einen Zaehler).
|
|
*/
|
|
export function ipSchluessel(ip) {
|
|
const s = typeof ip === 'string' ? ip.trim().toLowerCase() : ''
|
|
if (isIPv4(s)) return s
|
|
if (!isIPv6(s)) return 'unbekannt'
|
|
const gruppen = ipv6Gruppen(s)
|
|
if (!gruppen) return 'unbekannt'
|
|
const werte = gruppen.map((g) => parseInt(g, 16))
|
|
// ::ffff:a.b.c.d (Gruppen 0-4 null, Gruppe 5 = ffff) ist ein IPv4-Client hinter einem Dual-Stack-Socket
|
|
if (werte.slice(0, 5).every((w) => w === 0) && werte[5] === 0xffff) {
|
|
return `${werte[6] >> 8}.${werte[6] & 255}.${werte[7] >> 8}.${werte[7] & 255}`
|
|
}
|
|
return `v6:${werte.slice(0, 4).map((w) => w.toString(16).padStart(4, '0')).join(':')}`
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
|
|
/** Darf diese Funktion (siteKey) jetzt einen lesenden Abruf beantworten? Hoechstens 600 je Minute fuer alle zusammen. */
|
|
export function darfAnfrageSite(siteKey, now) {
|
|
return zaehle(`anfrage-site:${siteKey}`, ANFRAGEN_JE_SITE, MINUTE_MS, 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)
|
|
}
|
|
|
|
/** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */
|
|
export function darfHaltung(siteKey, now) {
|
|
return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, 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)
|
|
}
|
|
|
|
/** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */
|
|
export function darfG01(featureId, adresse, now) {
|
|
return zaehle(`g01:${featureId}:${adresse}`, G01_JE_ADRESSE, TAG_MS, now)
|
|
}
|
|
|
|
/** Leert alle Zaehler (Tests). */
|
|
export function resetTermineLimitsForTests() {
|
|
zaehler.clear()
|
|
}
|