Files
Webklar-Kundenbereich/server/services/termine/limits.js
T

234 lines
12 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). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist
* ein ganzes /64, die Adresse darin wechselt beliebig.
* Empfaenger-Kappe: eine fremde Adresse bekommt ueber alle Betriebe (Funktionen) zusammen hoechstens 3 Mails je Stunde und
* 10 je Tag, die ein Gast oder ein Betrieb ausloest (darfG01 beim Gast-Formular, darfEmpfaenger fuer den Freitext des
* Betriebs in G04/G06, mails.js anGast); beide teilen dieselben Zaehler.
*
* 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
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 = 25
/** 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
/** Empfaenger-Kappe ueber alle Funktionen: je (normalisierter) Adresse und Stunde, je Adresse und Tag. */
const EMPFAENGER_JE_STUNDE = 3
const EMPFAENGER_JE_TAG = 10
/**
* 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 = 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
* @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
else beginne(schluessel, fensterMs, nowMs)
}
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(`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. */
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(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now)
}
/**
* Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 25 je Stunde: so loest eine Funktion hoechstens 25 G01
* je Stunde aus, unter dem Mailkontingent je Funktion (config.termine.mailsProFunktion, outbox.js).
*/
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(`ip:widerruf:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now)
}
/** Die zwei Regeln der Empfaenger-Kappe (Stunde und Tag) fuer eine normalisierte Adresse, ueber alle Funktionen. */
const empfaengerRegeln = (adresse) => [
{ schluessel: `empfaenger-h:${adresse}`, max: EMPFAENGER_JE_STUNDE, fensterMs: STUNDE_MS },
{ schluessel: `empfaenger-d:${adresse}`, max: EMPFAENGER_JE_TAG, fensterMs: TAG_MS },
]
/**
* Darf an diese (normalisierte: klein, getrimmt) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag
* (gehalten wie "zu viele") und die Empfaenger-Kappe ueber alle Funktionen (3 je Stunde, 10 je Tag). Alles oder nichts:
* eine abgelehnte Anfrage erhoeht keinen Zaehler.
*/
export function darfG01(featureId, adresse, now) {
return zaehleAlle([{ schluessel: `g01:${featureId}:${adresse}`, max: G01_JE_ADRESSE, fensterMs: TAG_MS }, ...empfaengerRegeln(adresse)], now)
}
/**
* Darf an diese (normalisierte) Adresse eine Mail mit Freitext des Betriebs gehen (G04, G06)? Dieselben Zaehler wie
* darfG01 (3 je Stunde, 10 je Tag ueber alle Funktionen), aber ohne die Regel je Funktion.
*/
export function darfEmpfaenger(adresse, now) {
return zaehleAlle(empfaengerRegeln(adresse), now)
}
/** Leert alle Zaehler (Tests). */
export function resetTermineLimitsForTests() {
for (const speicher of [ipZaehler, sonstigeZaehler]) {
speicher.map.clear()
speicher.fruehesterAblauf = Infinity
}
}