Files
Webklar-Kundenbereich/server/services/termine/limits.js
T
ClaudeandClaude Sonnet 5.5 2b0c05b8e9 Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz
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>
2026-10-03 08:17:43 +00:00

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()
}