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>
This commit is contained in:
1 parent
43ac05aa06
commit
2b0c05b8e9
10 files changed
+1928
-7
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { isIPv4, isIPv6 } from 'node:net'
|
||||
import { pruefeNow, pruefeText } from './util.js'
|
||||
|
||||
/**
|
||||
@@ -8,9 +9,12 @@ import { pruefeNow, pruefeText } from './util.js'
|
||||
* 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 (Aufgabe 13): darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende
|
||||
* Buchung, ein Treffer bekommt seine Mails immer. Die Grenzen je IP, siteKey und G01 ergaenzt
|
||||
* Aufgabe 16 in diesem Modul.
|
||||
* 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
|
||||
@@ -23,6 +27,20 @@ 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()
|
||||
|
||||
@@ -80,6 +98,74 @@ 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()
|
||||
|
||||
Reference in new issue
Block a user