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]>
105 lines
3.9 KiB
JavaScript
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;
|
|
}
|