Files
dogfather-universe/server-internal/lib/kontingent.js
T
DogFatherGitandClaude Opus 5 a612a0f0fd Anfragebremse für /submit und /testimonials/submit (die letzten zwei ungebremsten Routen)
Bei der Bestandsaufnahme als "keine Route hat eine Bremse" gemeldet --
das war falsch (grep suchte nach rateLimit/bremse, im Code heißen sie
Kontingent/Sperre/Fehlversuche). Fast alle öffentlichen Routen SIND
gebremst: Anmeldung, Supporter-Login, Upload, Kundenanfragen. Übrig
blieben genau zwei schreibende Routen: Bewerbungen und Stimmen.

Ohne Bremse könnte ein Skript die Datenbank mit Müll fluten. Kein
Sicherheitsleck (beide landen in einer Warteschlange, nichts wird
ungesehen veröffentlicht), aber eine sinnvolle Härtung.

Statt das vorhandene Muster ein drittes Mal zu kopieren: ein Baustein
lib/kontingent.js, den nun alle drei Routen nutzen. Zwei Verbesserungen
gegenüber dem Original in testimonials.js:
- getrennte Töpfe je Zweck (ein Bild-Upload verbraucht kein
  Bewerbungs-Kontingent)
- Selbstreinigung: die alte Zähler-Map ließ jede IP für immer im
  Speicher stehen (langsames Leck), die neue räumt abgelaufene Einträge auf

Grenzen: Uploads 10/Stunde/IP (belegen Plattenplatz), Text-Einreichungen
20/Stunde/IP (großzügig für geteilte Anschlüsse, stoppt Fluten).

Tests: pruef-kontingent.mjs (Baustein, 7/7), test-kontingent-routen.mjs
(echte Routen liefern 429 ab Grenze, getrennte Töpfe, IPs unabhängig,
4/4). Bestehende Tests unverändert grün (37/37).

NOCH NICHT LIVE: server-internal läuft aus /home/dogiintern (kein Zugriff),
wird mit den übrigen Server-Änderungen in einem Deploy live geschaltet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 10:35:54 +02:00

105 lines
3.9 KiB
JavaScript

/* =====================================================================
kontingent.js — eine Anfragebremse pro IP, ohne Fremdpaket
WOZU
Öffentliche Routen, die in die Datenbank schreiben oder Dateien
annehmen, brauchen eine Obergrenze pro Absender. Sonst füllt ein
einzelnes Skript die Datenbank mit Müll oder die Platte mit Bildern.
Es gab diese Bremse schon — aber nur an einer Stelle (der Bild-Upload
in routes/testimonials.js) und dort fest verdrahtet. Zwei weitere
öffentliche Schreibrouten (/submit, /testimonials/submit) hatten
keine. Statt das Muster ein drittes Mal zu kopieren, steht es nun
einmal hier und wird überall gleich benutzt.
ZWEI VERBESSERUNGEN GEGENÜBER DEM ORIGINAL
1. Getrennte Töpfe je Zweck: Der Bild-Upload und die Bewerbung teilen
sich NICHT dasselbe Kontingent. Wer ein Bild hochlädt, verbraucht
damit keinen seiner Bewerbungsversuche.
2. Die Zählung räumt sich selbst auf. Das Original ließ jede IP, die
je eine Anfrage geschickt hatte, für immer im Speicher stehen — ein
langsames Leck. Hier werden abgelaufene Einträge bei Gelegenheit
entfernt.
BEWUSSTE GRENZE
Der Zähler liegt im Arbeitsspeicher, nicht in der Datenbank. Ein
Neustart setzt ihn zurück, und bei mehreren Prozessen zählte jeder für
sich. Beides ist hier unkritisch: Es gibt genau einen Prozess, und ein
Neustart, der das Kontingent leert, schadet niemandem — im Gegenteil,
ein zu Unrecht Gebremster käme dann wieder durch. Für eine echte,
prozessübergreifende Bremse wäre die Datenbank der Ort; der Aufwand
lohnt hier (noch) nicht.
HINTER CADDY
Die IP kommt aus req.ip. Das ist nur dann die ECHTE Besucher-IP, wenn
express dem Proxy vertraut — app.set("trust proxy", 1) ist in index.js
gesetzt (mit Begründung in lib/auth.js). Ohne das wäre req.ip für alle
Besucher 127.0.0.1 und die Bremse träfe alle gemeinsam.
===================================================================== */
const STUNDE = 60 * 60 * 1000;
/* Ein Topf je Zweck. Schlüssel ist der Zweckname, Wert eine Map von
IP -> { anzahl, fensterStart }. */
const toepfe = new Map();
/* Aufräumen ist beiläufig, nicht auf die Uhr getaktet: Bei jedem N-ten
Aufruf wird der eigene Topf einmal von abgelaufenen Einträgen befreit.
Das kostet nichts extra (kein Timer, der auch im Leerlauf läuft) und
hält den Speicher trotzdem klein. */
let aufrufeSeitReinigung = 0;
const REINIGEN_ALLE = 500;
function reinige(topf, jetzt, fensterMs) {
for (const [ip, e] of topf) {
if (jetzt - e.fensterStart > fensterMs) topf.delete(ip);
}
}
/**
* Fragt die Bremse und zählt den Versuch zugleich.
*
* @param {string} zweck getrennter Topf, z.B. "bewerbung", "stimme"
* @param {string} ip req.ip des Aufrufers
* @param {object} [opt]
* @param {number} [opt.maxProFenster=20] erlaubte Anfragen je Fenster
* @param {number} [opt.fensterMs=1h] Länge des Fensters
* @returns {boolean} true = erlaubt (und gezählt), false = Grenze erreicht
*/
export function kontingentFrei(zweck, ip, opt = {}) {
const maxProFenster = opt.maxProFenster ?? 20;
const fensterMs = opt.fensterMs ?? STUNDE;
const schluessel = ip || "unbekannt";
let topf = toepfe.get(zweck);
if (!topf) { topf = new Map(); toepfe.set(zweck, topf); }
const jetzt = Date.now();
if (++aufrufeSeitReinigung >= REINIGEN_ALLE) {
aufrufeSeitReinigung = 0;
reinige(topf, jetzt, fensterMs);
}
const eintrag = topf.get(schluessel);
if (!eintrag || jetzt - eintrag.fensterStart > fensterMs) {
/* Neues Fenster: erster Versuch zählt sofort. */
topf.set(schluessel, { anzahl: 1, fensterStart: jetzt });
return true;
}
if (eintrag.anzahl >= maxProFenster) return false;
eintrag.anzahl += 1;
return true;
}
/* Nur für Tests: alle Töpfe leeren, damit ein Testfall nicht den nächsten
beeinflusst. */
export function _zuruecksetzen() {
toepfe.clear();
aufrufeSeitReinigung = 0;
}