Compare commits
2
Commits
46563b02fc
...
a612a0f0fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a612a0f0fd | ||
|
|
55775b53ea |
@@ -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;
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
Stürzt der Dienst bei dem multer-Fehler wirklich ab?
|
||||||
|
|
||||||
|
WARUM DIESER TEST
|
||||||
|
|
||||||
|
Am 27.08.2026 hatte ich gemeldet: multer 1.4.5-lts.2 hat eine
|
||||||
|
DoS-Lücke (CVE-2025-7338), ein fehlerhafter Upload beendet den
|
||||||
|
Prozess, nach fünf Malen gibt systemd auf -> Dienst dauerhaft tot.
|
||||||
|
|
||||||
|
Beim Nacharbeiten fiel mir das Ende von index.js auf:
|
||||||
|
|
||||||
|
process.on("uncaughtException", (fehler) => console.error(...));
|
||||||
|
|
||||||
|
Ein registrierter uncaughtException-Handler verhindert genau das
|
||||||
|
Beenden, das der CVE beschreibt. Damit steht meine eigene Meldung in
|
||||||
|
Frage -- und eine Sicherheitsmeldung, die dramatischer klingt als die
|
||||||
|
Wirklichkeit, ist genauso schädlich wie eine übersehene Lücke: Beim
|
||||||
|
nächsten Mal glaubt sie niemand mehr.
|
||||||
|
|
||||||
|
Also gemessen statt vermutet. Der Test baut denselben Aufbau nach,
|
||||||
|
den der echte Dienst hat, schickt den bösartigen Request und schaut,
|
||||||
|
ob der Prozess danach noch antwortet.
|
||||||
|
===================================================================== */
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { writeFileSync, rmSync, mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const wurzel = mkdtempSync(join(tmpdir(), "multertest-"));
|
||||||
|
const PORT = 47311;
|
||||||
|
|
||||||
|
/* Der Nachbau: dieselben multer-Einstellungen wie in index.js, und --
|
||||||
|
entscheidend -- derselbe uncaughtException-Handler am Ende. */
|
||||||
|
const server = `
|
||||||
|
import express from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
/* Jeder mögliche Weg wird laut protokolliert, damit sichtbar wird,
|
||||||
|
WELCHER genommen wird -- nicht nur, ob der Dienst überlebt. */
|
||||||
|
process.on("uncaughtException", (f) => console.error("WEG=uncaughtException", f?.message || f));
|
||||||
|
process.on("unhandledRejection", (g) => console.error("WEG=unhandledRejection", g?.message || g));
|
||||||
|
const app = express();
|
||||||
|
const up = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, ${JSON.stringify(wurzel).replace(/\\\\/g, "/")}),
|
||||||
|
filename: (req, file, cb) => cb(null, "x" + Date.now()),
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => cb(null, ["image/jpeg","image/png","image/webp"].includes(file.mimetype)),
|
||||||
|
});
|
||||||
|
app.post("/upload", (req, res) => {
|
||||||
|
up.single("image")(req, res, (err) => {
|
||||||
|
if (err) { console.error("WEG=callback-fehler", err?.code || err?.message); return res.status(400).json({ ok:false }); }
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.get("/lebt", (req, res) => res.json({ ok: true }));
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error("WEG=express-fehlerhandler", err?.code || err?.message);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
});
|
||||||
|
app.listen(${PORT}, () => console.log("BEREIT"));
|
||||||
|
`;
|
||||||
|
/* Die Datei MUSS neben den Paketen liegen: Node sucht node_modules von
|
||||||
|
der Datei aus aufwärts, nicht vom Arbeitsverzeichnis. In /tmp abgelegt
|
||||||
|
findet sie weder express noch multer und startet gar nicht erst. */
|
||||||
|
const datei = join(process.cwd(), "server-internal", "tmp-dos-nachbau.mjs");
|
||||||
|
writeFileSync(datei, server);
|
||||||
|
|
||||||
|
const kind = spawn(process.execPath, [datei], { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let ausgabe = "";
|
||||||
|
kind.stdout.on("data", (d) => { ausgabe += d; });
|
||||||
|
kind.stderr.on("data", (d) => { ausgabe += d; });
|
||||||
|
|
||||||
|
await new Promise((f) => {
|
||||||
|
const warte = setInterval(() => { if (ausgabe.includes("BEREIT")) { clearInterval(warte); f(); } }, 100);
|
||||||
|
setTimeout(() => { clearInterval(warte); f(); }, 8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const version = (await import("./server-internal/node_modules/multer/package.json", { with: { type: "json" } })).default.version;
|
||||||
|
console.log("\ngetestete multer-Fassung:", version);
|
||||||
|
console.log("laeuft der Nachbau? ", ausgabe.includes("BEREIT") ? "ja" : "NEIN — Abbruch");
|
||||||
|
if (!ausgabe.includes("BEREIT")) {
|
||||||
|
console.log("Ausgabe:", ausgabe.slice(0, 400));
|
||||||
|
kind.kill(); rmSync(wurzel, { recursive: true, force: true }); rmSync(datei, { force: true }); process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lebt = async () => {
|
||||||
|
try {
|
||||||
|
const a = await fetch(`http://127.0.0.1:${PORT}/lebt`, { signal: AbortSignal.timeout(3000) });
|
||||||
|
return a.ok;
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("\n--- vorher ---");
|
||||||
|
console.log(" Dienst antwortet:", await lebt() ? "ja" : "nein");
|
||||||
|
|
||||||
|
/* Der bösartige Request, diesmal RICHTIG.
|
||||||
|
|
||||||
|
fetch() taugt dafür nicht: Es sendet immer einen vollständigen, in
|
||||||
|
sich stimmigen Request. Der Angriff aus CVE-2025-7338 besteht aber
|
||||||
|
genau darin, dass der Client MEHR Daten ankündigt (content-length),
|
||||||
|
als er sendet, und dann die Verbindung schließt. multer wartet auf
|
||||||
|
den Rest, das Formular endet unerwartet -> "Unexpected end of form".
|
||||||
|
|
||||||
|
Das geht nur über einen rohen Socket, den man von Hand abschneidet. */
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
function boesartigerUpload(nr) {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
const grenze = "----angriff" + nr;
|
||||||
|
/* Ein gueltig BEGONNENER Multipart-Teil, der busboy zum Parsen bringt,
|
||||||
|
dann aber ohne Abschluss-Grenze endet. */
|
||||||
|
const teil =
|
||||||
|
`--${grenze}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="image"; filename="a.png"\r\n` +
|
||||||
|
`Content-Type: image/png\r\n\r\n` +
|
||||||
|
`ANFANG-DER-DATEN-OHNE-ENDE`;
|
||||||
|
/* chunked statt content-length: Ich sende einen gueltigen Chunk und
|
||||||
|
schliesse dann die Verbindung, OHNE den abschliessenden 0-Chunk.
|
||||||
|
Fuer busboy sieht das aus wie ein Formular, das mitten im Lesen
|
||||||
|
abreisst -- genau der Ausloeser fuer "Unexpected end of form".
|
||||||
|
Anders als beim harten destroy() hat Node den Request hier bereits
|
||||||
|
an Express uebergeben, multer parst also schon. */
|
||||||
|
const chunk = teil.length.toString(16) + "\r\n" + teil + "\r\n";
|
||||||
|
const kopf =
|
||||||
|
`POST /upload HTTP/1.1\r\n` +
|
||||||
|
`Host: 127.0.0.1:${PORT}\r\n` +
|
||||||
|
`Content-Type: multipart/form-data; boundary=${grenze}\r\n` +
|
||||||
|
`Transfer-Encoding: chunked\r\n` +
|
||||||
|
`Connection: close\r\n\r\n`;
|
||||||
|
let erledigt = false;
|
||||||
|
const schluss = () => { if (!erledigt) { erledigt = true; setTimeout(fertig, 200); } };
|
||||||
|
const s = net.connect(PORT, "127.0.0.1", () => {
|
||||||
|
s.write(kopf + chunk);
|
||||||
|
/* busboy Zeit zum Anlesen geben, dann Verbindung beenden -- der
|
||||||
|
0-Abschluss-Chunk kommt nie. */
|
||||||
|
setTimeout(() => s.end(), 300);
|
||||||
|
});
|
||||||
|
s.on("error", () => {});
|
||||||
|
s.on("close", schluss);
|
||||||
|
/* Harte Grenze: Ein haengender Request (busboy wartet ewig auf den
|
||||||
|
Rest) darf den Test nicht blockieren -- das Haengen IST bereits ein
|
||||||
|
Befund, nur eben "Ressourcenleck", nicht "Absturz". */
|
||||||
|
setTimeout(() => { try { s.destroy(); } catch {} schluss(); }, 2500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- fehlerhafter Upload wird geschickt (roher Socket) ---");
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
await boesartigerUpload(i);
|
||||||
|
const l = await lebt();
|
||||||
|
console.log(` Versuch ${i}: Dienst danach ${l ? "lebt" : "TOT"}`);
|
||||||
|
if (!l) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- Ergebnis ---");
|
||||||
|
const amLeben = await lebt();
|
||||||
|
console.log(amLeben
|
||||||
|
? " Der Dienst lebt nach allen Versuchen. Absturz NICHT ausgeloest."
|
||||||
|
: " Der Dienst ist TOT. Die Lücke beendet den Prozess wie beschrieben.");
|
||||||
|
console.log("\n--- welchen Weg nahm der Fehler? ---");
|
||||||
|
const wege = ausgabe.split("\n").filter((z) => z.includes("WEG=")).map((z) => z.trim());
|
||||||
|
if (!wege.length) {
|
||||||
|
console.log(" KEINEN — der Angriff hat multer gar nicht erreicht. Test aussagelos.");
|
||||||
|
} else {
|
||||||
|
const zaehl = {};
|
||||||
|
for (const w of wege) { const k = w.split(" ")[0]; zaehl[k] = (zaehl[k] || 0) + 1; }
|
||||||
|
for (const [k, n] of Object.entries(zaehl)) console.log(` ${k} (${n}x)`);
|
||||||
|
console.log(" Beispiel:", wege[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
kind.kill();
|
||||||
|
rmSync(wurzel, { recursive: true, force: true });
|
||||||
|
rmSync(datei, { force: true });
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,9 +11,16 @@ import { logAction } from "../lib/audit.js";
|
|||||||
import { json } from "../lib/http.js";
|
import { json } from "../lib/http.js";
|
||||||
import { bewerbungAlsTicketAnlegen } from "../lib/ticketanizer.js";
|
import { bewerbungAlsTicketAnlegen } from "../lib/ticketanizer.js";
|
||||||
import { bewerbungAlsDiscordNachrichtPosten } from "../lib/discord-webhooks.js";
|
import { bewerbungAlsDiscordNachrichtPosten } from "../lib/discord-webhooks.js";
|
||||||
|
import { kontingentFrei } from "../lib/kontingent.js";
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["creator", "scout", "kooperation", "modi"];
|
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) {
|
function can(session, key) {
|
||||||
if (!session) return false;
|
if (!session) return false;
|
||||||
if (session.isOwner) return true;
|
if (session.isOwner) return true;
|
||||||
@@ -29,6 +36,10 @@ function viewPermissionForType(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function submitApplication(req, res) {
|
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 body = req.body;
|
||||||
const { type, data } = body || {};
|
const { type, data } = body || {};
|
||||||
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
|
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { generateId, nowIso } from "../lib/crypto.js";
|
|||||||
import { hasPermission } from "../lib/permissions.js";
|
import { hasPermission } from "../lib/permissions.js";
|
||||||
import { json } from "../lib/http.js";
|
import { json } from "../lib/http.js";
|
||||||
import { logAction } from "../lib/audit.js";
|
import { logAction } from "../lib/audit.js";
|
||||||
|
import { kontingentFrei } from "../lib/kontingent.js";
|
||||||
|
|
||||||
function can(session, key) {
|
function can(session, key) {
|
||||||
if (!session) return false;
|
if (!session) return false;
|
||||||
@@ -61,24 +62,13 @@ function istEigenesHochgeladenesBild(wert) {
|
|||||||
return /^[a-f0-9-]{36}\.(jpe?g|png|webp)$/i.test(datei);
|
return /^[a-f0-9-]{36}\.(jpe?g|png|webp)$/i.test(datei);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Einfache Missbrauchsbremse für den öffentlichen Bild-Upload (kein Login
|
/* Zwei Obergrenzen pro IP (Stunde). Beide über denselben Baustein
|
||||||
nötig). Ohne sie könnte jemand den Endpunkt in einer Schleife aufrufen
|
lib/kontingent.js, in getrennten Töpfen -- ein Bild-Upload verbraucht
|
||||||
und die Festplatte des Servers vollschreiben. Bewusst schlicht im
|
also kein Kontingent für Einreichungen und umgekehrt.
|
||||||
Arbeitsspeicher gehalten: es gibt hier nur einen Serverprozess, und ein
|
Upload strenger (10), weil er Speicher auf der Platte belegt; die
|
||||||
Neustart (der den Zähler leert) ist kein Schaden. */
|
reine Text-Einreichung großzügiger (20). */
|
||||||
const UPLOAD_MAX_PRO_STUNDE = 10;
|
const UPLOAD_MAX_PRO_STUNDE = 10;
|
||||||
const uploadZaehler = new Map(); // ip -> { anzahl, fensterStart }
|
const STIMME_MAX_PRO_STUNDE = 20;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Öffentlich (ohne Login): Profilbild für die eigene Einreichung hochladen.
|
/* Öffentlich (ohne Login): Profilbild für die eigene Einreichung hochladen.
|
||||||
Die Datei ist danach zwar unter ihrer zufälligen Adresse abrufbar, taucht
|
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). */
|
Größe begrenzt multer (nur JPG/PNG/WebP, max. 5 MB, zufälliger Name). */
|
||||||
export async function uploadEigenesTestimonialBild(req, res) {
|
export async function uploadEigenesTestimonialBild(req, res) {
|
||||||
const ip = req.ip || req.socket?.remoteAddress || "unbekannt";
|
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);
|
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);
|
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,
|
Schutzschicht gegen Missbrauch ist die manuelle Freigabe durch Dogi,
|
||||||
kein Eintrag wird je ungesehen veröffentlicht. */
|
kein Eintrag wird je ungesehen veröffentlicht. */
|
||||||
export async function submitTestimonial(req, res) {
|
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 body = req.body || {};
|
||||||
const name = String(body.name || "").trim().slice(0, 60);
|
const name = String(body.name || "").trim().slice(0, 60);
|
||||||
const tiktokUsername = String(body.tiktokUsername || "").trim().replace(/^@/, "").slice(0, 40) || null;
|
const tiktokUsername = String(body.tiktokUsername || "").trim().replace(/^@/, "").slice(0, 40) || null;
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user