210 lines
10 KiB
JavaScript
210 lines
10 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.
|
|
*
|
|
* 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
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
/** 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() {
|
|
for (const speicher of [ipZaehler, sonstigeZaehler]) {
|
|
speicher.map.clear()
|
|
speicher.fruehesterAblauf = Infinity
|
|
}
|
|
}
|