Terminplaner: Zaehler je IP getrennt und Abgelaufene zuerst, Notantwort beim Schreiben, Pruefung aller Antworten auf interne IDs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b0c05b8e9
commit
ae1886c030
4 files changed
+226
-31
No files matched your search
@@ -143,6 +143,43 @@ function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
|
||||
return res.end(inhalt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert (z. B. nicht serialisierbarer Wert): ein schlichtes 503
|
||||
* mit fester Meldung, sofern noch nichts gesendet wurde; sonst wird die Antwort beendet. Eine Anfrage bleibt nie haengen und
|
||||
* der async Handler lehnt nie unbehandelt ab (Express 4 faengt das nicht).
|
||||
*/
|
||||
function notantwort(res, err) {
|
||||
try {
|
||||
console.error(`[publicTermine] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
} catch {
|
||||
// das Log darf die Notantwort nicht verhindern
|
||||
}
|
||||
try {
|
||||
if (res.headersSent) return void res.end()
|
||||
const inhalt = JSON.stringify(NICHT_ERREICHBAR)
|
||||
res.statusCode = 503
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII
|
||||
return void res.end(inhalt)
|
||||
} catch {
|
||||
try {
|
||||
res.destroy()
|
||||
} catch {
|
||||
// nichts mehr zu tun
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** schreibe, aber mit der Notantwort statt eines Fehlers (fuer Stellen ausserhalb von route). */
|
||||
function schreibeSicher(req, res, ergebnis, cors) {
|
||||
try {
|
||||
schreibe(req, res, ergebnis, cors)
|
||||
} catch (err) {
|
||||
notantwort(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
/** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Funktionen (GET kurz im Browser-Cache). */
|
||||
const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' })
|
||||
|
||||
@@ -154,18 +191,22 @@ const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
* schneller als config.termine.minAntwortMs (ab Beginn der Anfrage, auch beim Lesen des Rumpfes).
|
||||
*/
|
||||
const route = (fn, { mindestdauer = false } = {}) => async (req, res) => {
|
||||
const cors = { erlaubt: null }
|
||||
let ergebnis
|
||||
try {
|
||||
ergebnis = await fn(req, uhr(), cors)
|
||||
const cors = { erlaubt: null }
|
||||
let ergebnis
|
||||
try {
|
||||
ergebnis = await fn(req, uhr(), cors)
|
||||
} catch (err) {
|
||||
ergebnis = fehlerErgebnis(err)
|
||||
}
|
||||
if (mindestdauer) {
|
||||
const rest = config.termine.minAntwortMs - (performance.now() - req.termineStart)
|
||||
if (rest > 0) await warte(rest)
|
||||
}
|
||||
schreibe(req, res, ergebnis, cors)
|
||||
} catch (err) {
|
||||
ergebnis = fehlerErgebnis(err)
|
||||
notantwort(res, err)
|
||||
}
|
||||
if (mindestdauer) {
|
||||
const rest = config.termine.minAntwortMs - (performance.now() - req.termineStart)
|
||||
if (rest > 0) await warte(rest)
|
||||
}
|
||||
schreibe(req, res, ergebnis, cors)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Herkunft
|
||||
@@ -210,7 +251,7 @@ router.use((req, res, next) => {
|
||||
// Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen).
|
||||
if (Number(req.get('content-length')) > BODY_MAX) {
|
||||
res.set('Connection', 'close')
|
||||
return schreibe(req, res, { status: 413, body: ZU_GROSS })
|
||||
return schreibeSicher(req, res, { status: 413, body: ZU_GROSS })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
@@ -530,13 +571,13 @@ router.options(
|
||||
|
||||
// ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers
|
||||
|
||||
router.use((req, res) => schreibe(req, res, { status: 404, body: NICHT_GEFUNDEN }))
|
||||
router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN }))
|
||||
|
||||
// Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers
|
||||
router.use((err, req, res, _next) => {
|
||||
if (err?.type === 'entity.too.large' || err?.status === 413) return schreibe(req, res, { status: 413, body: ZU_GROSS })
|
||||
if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibe(req, res, { status: 400, body: UNGUELTIG })
|
||||
return schreibe(req, res, fehlerErgebnis(err))
|
||||
if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS })
|
||||
if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG })
|
||||
return schreibeSicher(req, res, fehlerErgebnis(err))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -15,6 +15,12 @@ import { pruefeNow, pruefeText } from './util.js'
|
||||
* 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
|
||||
@@ -41,15 +47,45 @@ 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()
|
||||
/**
|
||||
* 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 = zaehler.get(schluessel)
|
||||
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
|
||||
@@ -66,13 +102,8 @@ export function zaehleAlle(regeln, now) {
|
||||
}
|
||||
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 })
|
||||
if (eintrag) eintrag.n += 1
|
||||
else beginne(schluessel, fensterMs, nowMs)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -138,7 +169,7 @@ export function ipSchluessel(ip) {
|
||||
|
||||
/** 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)
|
||||
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. */
|
||||
@@ -148,7 +179,7 @@ export function darfAnfrageSite(siteKey, 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)
|
||||
return zaehle(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now)
|
||||
}
|
||||
|
||||
/** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */
|
||||
@@ -158,7 +189,7 @@ export function darfHaltung(siteKey, 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)
|
||||
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"). */
|
||||
@@ -168,5 +199,8 @@ export function darfG01(featureId, adresse, now) {
|
||||
|
||||
/** Leert alle Zaehler (Tests). */
|
||||
export function resetTermineLimitsForTests() {
|
||||
zaehler.clear()
|
||||
for (const speicher of [ipZaehler, sonstigeZaehler]) {
|
||||
speicher.map.clear()
|
||||
speicher.fruehesterAblauf = Infinity
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user