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]>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user