diff --git a/pruef-kontingent.mjs b/pruef-kontingent.mjs new file mode 100644 index 00000000..03cb1bf9 --- /dev/null +++ b/pruef-kontingent.mjs @@ -0,0 +1,93 @@ +/* ===================================================================== + Tut die Anfragebremse, was sie soll? + + Geprüft wird das Verhalten, auf das es ankommt: Ab dem richtigen + Versuch sperren, verschiedene IPs und Zwecke sauber trennen, nach + Ablauf des Fensters wieder öffnen, und den Speicher nicht endlos + wachsen lassen. + ===================================================================== */ +import { kontingentFrei, _zuruecksetzen } from "./server-internal/lib/kontingent.js"; + +let ok = 0, fehl = 0; +const pruefe = (name, gut, info) => { + if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } + else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); } +}; + +console.log("\n=== Sperrt ab dem richtigen Versuch ==="); +{ + _zuruecksetzen(); + let durch = 0; + for (let i = 0; i < 8; i++) if (kontingentFrei("test", "1.1.1.1", { maxProFenster: 5 })) durch++; + pruefe("bei Grenze 5 kommen genau 5 durch", durch === 5, `${durch} von 8`); +} + +console.log("\n=== Verschiedene IPs stören sich nicht ==="); +{ + _zuruecksetzen(); + for (let i = 0; i < 5; i++) kontingentFrei("test", "2.2.2.2", { maxProFenster: 5 }); + const fremdeFrei = kontingentFrei("test", "3.3.3.3", { maxProFenster: 5 }); + pruefe("eine erschöpfte IP sperrt keine andere", fremdeFrei === true); +} + +console.log("\n=== Verschiedene Zwecke sind getrennte Töpfe ==="); +{ + _zuruecksetzen(); + for (let i = 0; i < 5; i++) kontingentFrei("bewerbung", "4.4.4.4", { maxProFenster: 5 }); + const stimmeFrei = kontingentFrei("stimme", "4.4.4.4", { maxProFenster: 5 }); + pruefe("Bewerbung erschöpft, Stimme derselben IP noch frei", stimmeFrei === true); +} + +console.log("\n=== Nach Ablauf des Fensters wieder offen ==="); +{ + _zuruecksetzen(); + /* Echtes, sehr kurzes Fenster (5 ms) und eine kurze reale Pause danach. + Ein 0-ms-Fenster taugt hier nicht: Zwei Aufrufe in derselben + Millisekunde haben Abstand 0, und 0 ist nicht GRÖSSER als 0 -- das + Fenster gilt dann als noch offen. Genau richtig so, aber für diesen + Test braucht es einen messbaren Zeitabstand. */ + for (let i = 0; i < 5; i++) kontingentFrei("test", "5.5.5.5", { maxProFenster: 5, fensterMs: 5 }); + const gesperrt = kontingentFrei("test", "5.5.5.5", { maxProFenster: 5, fensterMs: 5 }); + await new Promise((f) => setTimeout(f, 15)); + const nachAblauf = kontingentFrei("test", "5.5.5.5", { maxProFenster: 5, fensterMs: 5 }); + pruefe("innerhalb des Fensters gesperrt, nach Ablauf wieder frei", + gesperrt === false && nachAblauf === true, + `im Fenster: ${gesperrt ? "durch" : "gesperrt"}, nach Ablauf: ${nachAflaufText(nachAblauf)}`); +} +function nachAflaufText(v) { return v ? "frei" : "noch gesperrt"; } + +console.log("\n=== Unbekannte IP (null/undefined) sperrt nicht die Welt aus ==="); +{ + _zuruecksetzen(); + /* Fällt die IP aus (Proxy-Panne), landen alle im selben Topf "unbekannt". + Das ist bewusst so: lieber alle gemeinsam grob bremsen als gar nicht. + Der Test stellt nur sicher, dass es nicht abstürzt und die Grenze greift. */ + let durch = 0; + for (let i = 0; i < 8; i++) if (kontingentFrei("test", null, { maxProFenster: 5 })) durch++; + pruefe("null-IP wird gezählt statt zu crashen", durch === 5, `${durch} von 8`); +} + +console.log("\n=== Standardgrenze greift ohne Angabe ==="); +{ + _zuruecksetzen(); + let durch = 0; + for (let i = 0; i < 25; i++) if (kontingentFrei("test", "6.6.6.6")) durch++; + pruefe("ohne Angabe gilt 20 pro Stunde", durch === 20, `${durch} von 25`); +} + +console.log("\n=== Speicher läuft nicht voll (Selbstreinigung) ==="); +{ + _zuruecksetzen(); + /* 1200 verschiedene IPs mit sofort ablaufendem Fenster. Ohne Reinigung + blieben alle 1200 im Speicher. Mit Reinigung (alle 500 Aufrufe) muss + der Topf danach deutlich kleiner sein. Wir messen indirekt über einen + internen Zugriff — der Baustein gibt die Topfgröße nicht her, also + prüfen wir das Verhalten: nach vielen abgelaufenen IPs kommt eine + frische immer noch durch (kein Fehler, kein Bremsen). */ + for (let i = 0; i < 1200; i++) kontingentFrei("gross", "10.0." + (i >> 8) + "." + (i & 255), { fensterMs: 0 }); + const frischFrei = kontingentFrei("gross", "10.0.99.99", { fensterMs: 0 }); + pruefe("nach 1200 abgelaufenen IPs bremst nichts fälschlich", frischFrei === true); +} + +console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); +process.exitCode = fehl ? 1 : 0; diff --git a/server-internal/lib/kontingent.js b/server-internal/lib/kontingent.js new file mode 100644 index 00000000..b7a900d2 --- /dev/null +++ b/server-internal/lib/kontingent.js @@ -0,0 +1,104 @@ +/* ===================================================================== + 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; +} diff --git a/server-internal/routes/applications.js b/server-internal/routes/applications.js index 239fc21c..32fdd861 100644 --- a/server-internal/routes/applications.js +++ b/server-internal/routes/applications.js @@ -11,9 +11,16 @@ import { logAction } from "../lib/audit.js"; import { json } from "../lib/http.js"; import { bewerbungAlsTicketAnlegen } from "../lib/ticketanizer.js"; import { bewerbungAlsDiscordNachrichtPosten } from "../lib/discord-webhooks.js"; +import { kontingentFrei } from "../lib/kontingent.js"; const ALLOWED_TYPES = ["creator", "scout", "kooperation", "modi"]; +/* Öffentliche Route, keine Anmeldung: ohne Bremse könnte ein Skript die + Bewerbungstabelle mit Müll fluten. 20/Stunde/IP ist großzügig genug + für mehrere echte Bewerbungen hinter einem geteilten Anschluss + (Familie, Mobilfunk-NAT) und stoppt trotzdem eine maschinelle Flut. */ +const BEWERBUNG_MAX_PRO_STUNDE = 20; + function can(session, key) { if (!session) return false; if (session.isOwner) return true; @@ -29,6 +36,10 @@ function viewPermissionForType(type) { } export async function submitApplication(req, res) { + const ip = req.ip || req.socket?.remoteAddress || "unbekannt"; + if (!kontingentFrei("bewerbung", ip, { maxProFenster: BEWERBUNG_MAX_PRO_STUNDE })) { + return json(res, { ok: false, error: "Zu viele Bewerbungen in kurzer Zeit. Bitte versuch es später noch einmal." }, 429); + } const body = req.body; const { type, data } = body || {}; if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) { diff --git a/server-internal/routes/testimonials.js b/server-internal/routes/testimonials.js index 96fdc887..46c71c06 100644 --- a/server-internal/routes/testimonials.js +++ b/server-internal/routes/testimonials.js @@ -16,6 +16,7 @@ import { generateId, nowIso } from "../lib/crypto.js"; import { hasPermission } from "../lib/permissions.js"; import { json } from "../lib/http.js"; import { logAction } from "../lib/audit.js"; +import { kontingentFrei } from "../lib/kontingent.js"; function can(session, key) { if (!session) return false; @@ -61,24 +62,13 @@ function istEigenesHochgeladenesBild(wert) { return /^[a-f0-9-]{36}\.(jpe?g|png|webp)$/i.test(datei); } -/* Einfache Missbrauchsbremse für den öffentlichen Bild-Upload (kein Login - nötig). Ohne sie könnte jemand den Endpunkt in einer Schleife aufrufen - und die Festplatte des Servers vollschreiben. Bewusst schlicht im - Arbeitsspeicher gehalten: es gibt hier nur einen Serverprozess, und ein - Neustart (der den Zähler leert) ist kein Schaden. */ +/* Zwei Obergrenzen pro IP (Stunde). Beide über denselben Baustein + lib/kontingent.js, in getrennten Töpfen -- ein Bild-Upload verbraucht + also kein Kontingent für Einreichungen und umgekehrt. + Upload strenger (10), weil er Speicher auf der Platte belegt; die + reine Text-Einreichung großzügiger (20). */ const UPLOAD_MAX_PRO_STUNDE = 10; -const uploadZaehler = new Map(); // ip -> { anzahl, fensterStart } -function uploadKontingentFrei(ip) { - const jetzt = Date.now(); - const eintrag = uploadZaehler.get(ip); - if (!eintrag || jetzt - eintrag.fensterStart > 60 * 60 * 1000) { - uploadZaehler.set(ip, { anzahl: 1, fensterStart: jetzt }); - return true; - } - if (eintrag.anzahl >= UPLOAD_MAX_PRO_STUNDE) return false; - eintrag.anzahl += 1; - return true; -} +const STIMME_MAX_PRO_STUNDE = 20; /* Öffentlich (ohne Login): Profilbild für die eigene Einreichung hochladen. Die Datei ist danach zwar unter ihrer zufälligen Adresse abrufbar, taucht @@ -87,7 +77,7 @@ function uploadKontingentFrei(ip) { Größe begrenzt multer (nur JPG/PNG/WebP, max. 5 MB, zufälliger Name). */ export async function uploadEigenesTestimonialBild(req, res) { const ip = req.ip || req.socket?.remoteAddress || "unbekannt"; - if (!uploadKontingentFrei(ip)) { + if (!kontingentFrei("stimme-upload", ip, { maxProFenster: UPLOAD_MAX_PRO_STUNDE })) { return json(res, { ok: false, error: "Zu viele Uploads in kurzer Zeit. Bitte versuch es später nochmal." }, 429); } if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400); @@ -125,6 +115,10 @@ function vollesFeld(row) { Schutzschicht gegen Missbrauch ist die manuelle Freigabe durch Dogi, kein Eintrag wird je ungesehen veröffentlicht. */ export async function submitTestimonial(req, res) { + const ip = req.ip || req.socket?.remoteAddress || "unbekannt"; + if (!kontingentFrei("stimme", ip, { maxProFenster: STIMME_MAX_PRO_STUNDE })) { + return json(res, { ok: false, error: "Zu viele Nachrichten in kurzer Zeit. Bitte versuch es später noch einmal." }, 429); + } const body = req.body || {}; const name = String(body.name || "").trim().slice(0, 60); const tiktokUsername = String(body.tiktokUsername || "").trim().replace(/^@/, "").slice(0, 40) || null; diff --git a/server-internal/test-kontingent-routen.mjs b/server-internal/test-kontingent-routen.mjs new file mode 100644 index 00000000..85945c9d --- /dev/null +++ b/server-internal/test-kontingent-routen.mjs @@ -0,0 +1,85 @@ +/* ===================================================================== + test-kontingent-routen.mjs — die Anfragebremse an den echten Routen + + Prüft nicht den Baustein selbst (das tut pruef-kontingent.mjs im + Projektstamm), sondern dass die beiden bisher ungebremsten Routen + submitApplication und submitTestimonial ihn WIRKLICH benutzen: + ab der Grenze kommt 429, und verschiedene IPs stören sich nicht. + + Läuft gegen eine Wegwerf-Datenbank, ruft die Handler direkt mit + Attrappen auf -- gleiches Muster wie test-webdesign-anfragen.mjs. + + Aufruf (aus server-internal/): node test-kontingent-routen.mjs + ===================================================================== */ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), "kont-test-")), "test.db"); + +const { initDb } = await import("./db.js"); +initDb(); + +const { submitApplication } = await import("./routes/applications.js"); +const { submitTestimonial } = await import("./routes/testimonials.js"); + +let ok = 0, schlecht = 0; +const pruefe = (name, bedingung, zusatz = "") => { + if (bedingung) { ok++; console.log(" ok " + name); } + else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); } +}; + +function antwortAttrappe() { + const a = { code: 200, daten: null }; + a.status = (c) => { a.code = c; return a; }; + a.json = (d) => { a.daten = d; return a; }; + return a; +} +function req(koerper, ip) { + return { body: koerper, params: {}, query: {}, headers: {}, ip, socket: { remoteAddress: ip } }; +} + +/* Eine gültige Bewerbung bzw. Stimme, damit NICHT eine Inhaltsprüfung + (Pflichtfeld o.ä.) fälschlich die 429-Messung verfälscht. */ +const bewerbung = () => ({ type: "creator", data: { name: "Test Person", warum: "Ich will dabei sein und mithelfen." } }); +const stimme = () => ({ name: "Test Person", tiktokUsername: "testi", nachricht: "Das Team ist wirklich super, ich bin gern dabei!" }); + +async function feuere(handler, koerperFn, ip, anzahl) { + let durch = 0, gesperrt = 0; + for (let i = 0; i < anzahl; i++) { + const res = antwortAttrappe(); + await handler(req(koerperFn(), ip), res); + if (res.code === 429) gesperrt++; + else durch++; + } + return { durch, gesperrt }; +} + +console.log("\n=== Bewerbungen: 20 pro Stunde, dann 429 ==="); +{ + const r = await feuere(submitApplication, bewerbung, "198.51.100.1", 25); + pruefe("genau 20 kommen durch, der Rest wird gebremst", r.durch === 20 && r.gesperrt === 5, `durch=${r.durch}, gesperrt=${r.gesperrt}`); +} + +console.log("\n=== Stimmen: 20 pro Stunde, dann 429 ==="); +{ + const r = await feuere(submitTestimonial, stimme, "198.51.100.2", 25); + pruefe("genau 20 kommen durch, der Rest wird gebremst", r.durch === 20 && r.gesperrt === 5, `durch=${r.durch}, gesperrt=${r.gesperrt}`); +} + +console.log("\n=== Getrennte Töpfe: erschöpfte Bewerbung sperrt keine Stimme ==="); +{ + await feuere(submitApplication, bewerbung, "198.51.100.3", 25); // Bewerbung erschöpfen + const r = await feuere(submitTestimonial, stimme, "198.51.100.3", 1); // Stimme derselben IP + pruefe("dieselbe IP darf noch eine Stimme abgeben", r.durch === 1 && r.gesperrt === 0); +} + +console.log("\n=== Verschiedene IPs stören sich nicht ==="); +{ + await feuere(submitApplication, bewerbung, "198.51.100.4", 25); // eine IP erschöpfen + const r = await feuere(submitApplication, bewerbung, "198.51.100.5", 1); // andere IP + pruefe("eine frische IP kommt durch", r.durch === 1 && r.gesperrt === 0); +} + +console.log(`\n===== ${ok} bestanden, ${schlecht} fehlgeschlagen =====`); +process.exitCode = schlecht ? 1 : 0;