Просмотр исходного кода

removing ratelimiting, only adding honeypot form as protection

Medowar 1 неделя назад
Родитель
Сommit
a70a2915da

+ 0 - 0
storage/rate_limit/.gitkeep → .codex


+ 0 - 11
api/delete-upload.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\FileSystem;
 use App\Storage\JsonStore;
 
@@ -107,16 +106,6 @@ function resolveStoredUploadPath(array $entry, array $app): ?array
 }
 
 $app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('delete-upload:%s:%s', $ip, $email);
-if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse([
-        'ok' => false,
-        'message' => Bootstrap::appMessage('delete_upload.rate_limited'),
-    ], 429);
-}
-
 $store = new JsonStore();
 
 try {

+ 0 - 12
api/load-draft.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\JsonStore;
 
 require dirname(__DIR__) . '/src/autoload.php';
@@ -56,17 +55,6 @@ if (($auth['ok'] ?? false) !== true) {
     ], (int) ($auth['status_code'] ?? 401));
 }
 
-$app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('load:%s:%s', $ip, $email);
-if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse([
-        'ok' => false,
-        'message' => Bootstrap::appMessage('load_draft.rate_limited'),
-    ], 429);
-}
-
 $store = new JsonStore();
 $submission = $store->getSubmissionByEmail($email);
 if ($submission !== null) {

+ 0 - 9
api/request-otp.php

@@ -6,7 +6,6 @@ use App\App\Bootstrap;
 use App\Mail\Mailer;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
@@ -32,14 +31,6 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
 $autoStartRaw = strtolower(trim((string) ($_POST['auto_start'] ?? '0')));
 $autoStart = in_array($autoStartRaw, ['1', 'true', 'yes'], true);
 
-$app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('otp-request:%s:%s', $ip, $email);
-if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Anfragen. Bitte später erneut versuchen.'], 429);
-}
-
 $formAccess = new FormAccess();
 $request = $formAccess->requestOtp($email, $autoStart);
 if (($request['ok'] ?? false) !== true) {

+ 0 - 10
api/reset.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\FileSystem;
 use App\Storage\JsonStore;
 
@@ -57,15 +56,6 @@ if (($auth['ok'] ?? false) !== true) {
 }
 
 $app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('reset:%s:%s', $ip, $email);
-if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse([
-        'ok' => false,
-        'message' => Bootstrap::appMessage('reset.rate_limited'),
-    ], 429);
-}
 
 $store = new JsonStore();
 

+ 0 - 12
api/save-draft.php

@@ -6,7 +6,6 @@ use App\App\Bootstrap;
 use App\Form\FormSchema;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\FileUploadStore;
 use App\Storage\JsonStore;
 
@@ -57,17 +56,6 @@ if (($auth['ok'] ?? false) !== true) {
     ], (int) ($auth['status_code'] ?? 401));
 }
 
-$app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('save:%s:%s', $ip, $email);
-if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse([
-        'ok' => false,
-        'message' => Bootstrap::appMessage('save_draft.rate_limited'),
-    ], 429);
-}
-
 $step = (int) ($_POST['step'] ?? 1);
 $formDataRaw = $_POST['form_data'] ?? [];
 $formData = [];

+ 0 - 10
api/submit.php

@@ -8,7 +8,6 @@ use App\Form\Validator;
 use App\Mail\Mailer;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\FileUploadStore;
 use App\Storage\JsonStore;
 
@@ -95,15 +94,6 @@ if (($auth['ok'] ?? false) !== true) {
 }
 
 $app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('submit:%s:%s', $ip, $email);
-if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse([
-        'ok' => false,
-        'message' => Bootstrap::appMessage('submit.rate_limited'),
-    ], 429);
-}
 
 $formDataRaw = $_POST['form_data'] ?? [];
 $formData = [];

+ 0 - 8
api/upload-preview.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 use App\Storage\JsonStore;
 
 require dirname(__DIR__) . '/src/autoload.php';
@@ -79,13 +78,6 @@ function resolveStoredPreviewPath(array $entry, array $app): ?string
 }
 
 $app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('preview-upload:%s:%s', $ip, $email);
-if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.rate_limited'), 429);
-}
-
 $store = new JsonStore();
 $draft = $store->getDraft($email);
 

+ 0 - 9
api/verify-otp.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Security\Csrf;
 use App\Security\FormAccess;
-use App\Security\RateLimiter;
 
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
@@ -30,14 +29,6 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
 
 $code = trim((string) ($_POST['otp_code'] ?? ''));
 
-$app = Bootstrap::config('app');
-$limiter = new RateLimiter();
-$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
-$rateKey = sprintf('otp-verify:%s:%s', $ip, $email);
-if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Anfragen. Bitte später erneut versuchen.'], 429);
-}
-
 $formAccess = new FormAccess();
 $result = $formAccess->verifyOtp($email, $code);
 if (($result['ok'] ?? false) !== true) {

+ 17 - 6
assets/js/form.js

@@ -52,6 +52,8 @@
   const wizardSection = document.getElementById('wizardSection');
   const applicationForm = document.getElementById('applicationForm');
   const applicationEmail = document.getElementById('applicationEmail');
+  const applicationWebsiteInput = document.getElementById('applicationWebsite');
+  const startWebsiteInput = document.getElementById('website');
   const progress = document.getElementById('progress');
   const prevBtn = document.getElementById('prevBtn');
   const nextBtn = document.getElementById('nextBtn');
@@ -194,6 +196,15 @@
     return String(code || '').replace(/[^\d]/g, '').slice(0, 6);
   }
 
+  function honeypotValue() {
+    const applicationValue = applicationWebsiteInput ? String(applicationWebsiteInput.value || '').trim() : '';
+    if (applicationValue !== '') {
+      return applicationValue;
+    }
+
+    return startWebsiteInput ? String(startWebsiteInput.value || '').trim() : '';
+  }
+
   function isValidEmail(email) {
     return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
   }
@@ -1227,7 +1238,7 @@
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', state.email);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('field', fieldKey);
     fd.append('index', String(index));
     fd.append('last_user_activity_at', String(state.lastUserActivityAt));
@@ -1477,7 +1488,7 @@
   async function requestOtpCode(email, autoStart) {
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('email', email);
     fd.append('auto_start', autoStart ? '1' : '0');
     return postForm(appUrl('api/request-otp.php'), fd);
@@ -1486,7 +1497,7 @@
   async function verifyOtpCode(email, code) {
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('email', email);
     fd.append('otp_code', code);
     return postForm(appUrl('api/verify-otp.php'), fd);
@@ -1497,7 +1508,7 @@
     fd.append('csrf', boot.csrf);
     fd.append('email', state.email);
     fd.append('step', String(Math.min(state.currentStep, state.totalSteps)));
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('last_user_activity_at', String(state.lastUserActivityAt));
 
     Array.from(applicationForm.elements).forEach((el) => {
@@ -1536,7 +1547,7 @@
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', email);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('last_user_activity_at', String(state.lastUserActivityAt));
     return postForm(appUrl('api/load-draft.php'), fd);
   }
@@ -1545,7 +1556,7 @@
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', email);
-    fd.append('website', '');
+    fd.append('website', honeypotValue());
     fd.append('last_user_activity_at', String(state.lastUserActivityAt));
     return postForm(appUrl('api/reset.php'), fd);
   }

+ 0 - 12
config/app.sample.php

@@ -33,11 +33,6 @@ return [
             'image/webp',
         ],
     ],
-    'rate_limit' => [
-        'enabled' => true,
-        'requests' => 30,
-        'window_seconds' => 300,
-    ],
     'verification' => [
         'required' => true,
         'inactivity_seconds' => 3600,
@@ -61,7 +56,6 @@ return [
         'drafts' => $root . '/storage/drafts',
         'submissions' => $root . '/storage/submissions',
         'uploads' => $root . '/storage/uploads',
-        'rate_limit' => $root . '/storage/rate_limit',
         'logs' => $root . '/storage/logs',
         'locks' => $root . '/storage/locks',
     ],
@@ -73,18 +67,15 @@ return [
             'invalid_email' => 'Bitte eine gültige E-Mail-Adresse eingeben.',
         ],
         'load_draft' => [
-            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
         ],
         'save_draft' => [
-            'rate_limited' => 'Ratelimited. Zu viele Speicheranfragen.',
             'already_submitted' => 'Für diese E-Mail wurde bereits ein Antrag abgeschlossen.',
             'lock_error' => 'Speichern derzeit nicht möglich.',
             'blocked_fallback' => 'Bereits abgeschlossen.',
             'success' => 'Entwurf gespeichert.',
         ],
         'submit' => [
-            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
             'upload_error' => 'Fehler bei Uploads.',
             'validation_error' => 'Bitte Pflichtfelder prüfen. Nicht alle Pflichtfeler sind ausgefüllt oder ungültige Werte vorhanden.',
@@ -93,14 +84,12 @@ return [
             'success' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',
         ],
         'reset' => [
-            'rate_limited' => 'Ratelimited. Zu viele Löschanfragen. Bitte später erneut versuchen.',
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor. Ein Zurücksetzen ist nicht möglich.',
             'delete_error' => 'Daten konnten nicht gelöscht werden.',
             'success' => 'Gespeicherte Daten wurden gelöscht.',
         ],
         'delete_upload' => [
             'invalid_upload_entry' => 'Ungültiger Upload-Eintrag.',
-            'rate_limited' => 'Ratelimited. Zu viele Löschanfragen. Bitte später erneut versuchen.',
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
             'draft_not_found' => 'Kein Entwurf gefunden.',
             'upload_not_found' => 'Upload nicht gefunden.',
@@ -109,7 +98,6 @@ return [
         ],
         'upload_preview' => [
             'invalid_upload_entry' => 'Ungültiger Upload-Eintrag.',
-            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
             'draft_not_found' => 'Entwurf nicht gefunden.',
             'upload_not_found' => 'Upload nicht gefunden.',
             'file_not_found' => 'Datei nicht gefunden.',

+ 1 - 2
docs/ai_overview.md

@@ -39,7 +39,6 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
   - `src/storage/filesystem.php`
   - `src/form/validator.php`
   - `src/security/csrf.php`
-  - `src/security/ratelimiter.php`
   - `src/mail/mailer.php` (HTML-Mails + PDF-Anhänge)
   - `src/mail/mimemailbuilder.php` (MIME-Mails via nativer mail()-Funktion)
   - `src/mail/pdfgenerator.php` (FPDF, Antrags- und Anlagen-PDFs)
@@ -70,7 +69,7 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
 - Admin-Session/Login: `config/app.local.php` + `admin/auth.php`
 - Mailtexte/Empfänger: `config/mail.local.php` + `src/mail/mailer.php`
 - Retention-Tage: `config/app.local.php` + Cron `admin/cleanup.php`
-- Rate-Limit-Parameter: `config/app.local.php -> rate_limit` (Details: `docs/rate_limiting.md`)
+- Honeypot-Feld im Frontend: `index.php` + `assets/js/form.js` (`website`)
 - Disclaimer-Startseite: `config/app.local.php -> disclaimer` + `index.php`
 - Versionskontrollierte Config-Vorlagen: `config/app.sample.php`, `config/mail.sample.php`
 - Lokale Runtime-Configs (nicht versioniert): `config/app.local.php`, `config/mail.local.php`

+ 0 - 2
docs/initial_setup.md

@@ -47,7 +47,6 @@ Diese Werte müssen gesetzt/validiert werden:
   - `submission_success_message`
   - `disclaimer.*`
   - `retention.*`
-  - `rate_limit.*`
   - `uploads.*`
   - `storage.*` (falls Speicherorte abweichend sein sollen)
 
@@ -76,7 +75,6 @@ Mindestens diese Verzeichnisse müssen für PHP schreibbar sein:
 - `storage/drafts`
 - `storage/submissions`
 - `storage/uploads`
-- `storage/rate_limit`
 - `storage/logs`
 - `storage/locks`
 

+ 4 - 7
docs/operations.md

@@ -38,13 +38,10 @@ php /pfad/zum/projekt/admin/cleanup.php
 - `storage/logs/php_runtime.log`
 - `storage/logs/php_fatal.log`
 
-## Rate Limiting
+## Bot-Schutz (Honeypot)
 
-- Konfiguration: `config/app.local.php -> rate_limit`
-- Persistenz: `storage/rate_limit/`
-- Detaillierte Doku: `docs/rate_limiting.md`
-- Bei erhöhten `429`-Antworten zuerst `requests/window_seconds` prüfen und gegen reale Nutzerlast kalibrieren.
-- Für Tests kann das Limiting global deaktiviert werden: `rate_limit.enabled = false`.
+- Das öffentliche Formular nutzt das Honeypot-Feld `website` als einfachen Spam-Schutz.
+- Anfragen mit nicht-leerem `website` werden mit HTTP `400` und `common.request_blocked` abgewiesen.
 
 ## Backup
 
@@ -69,5 +66,5 @@ Regelmäßig sichern:
 - Upload Fehler: `upload_max_filesize` / `post_max_size` und Schema-Limits prüfen.
 - Login geht nicht: `config/app.local.php -> admin.credentials` prüfen (username + password_hash).
 - ZIP Download fehlgeschlagen: `ZipArchive` Erweiterung auf Hosting prüfen.
-- Viele `429` Antworten: `docs/rate_limiting.md` prüfen, Limits anpassen oder `storage/rate_limit/` kontrollieren.
+- Unerwartet blockierte Requests (`400 Anfrage blockiert`): prüfen, ob das Feld `website` durch Client/Proxy/Plugin befüllt wird.
 - 500 ohne Apache/PHP-Fehlerausgabe: `storage/logs/php_fatal.log` und `storage/logs/php_runtime.log` prüfen.

+ 0 - 93
docs/rate_limiting.md

@@ -1,93 +0,0 @@
-# Rate Limiting
-
-## Zweck
-
-Schützt die API gegen Spam, Bot-Traffic und Missbrauch durch zu viele Anfragen in kurzer Zeit.
-
-## Implementierung
-
-- Klasse: `src/security/ratelimiter.php`
-- Strategie: Sliding-Window auf Basis von Zeitstempeln
-- Persistenz: Flat Files in `storage/rate_limit/`
-- Schlüsselablage: pro Key wird `sha256(key).json` verwendet
-- Sperren: `flock(LOCK_EX)` je Key-Datei
-
-## Konfiguration
-
-In `config/app.local.php`:
-
-- `rate_limit.enabled`
-Globaler Schalter (`true`/`false`). Bei `false` lässt der Limiter alle Requests durch.
-- `rate_limit.requests`
-Maximal erlaubte Requests pro Zeitfenster
-- `rate_limit.window_seconds`
-Länge des Zeitfensters in Sekunden
-
-Default:
-
-- `enabled = true`
-- `requests = 30`
-- `window_seconds = 300` (5 Minuten)
-
-## Aktuell geschützte Endpunkte
-
-- `POST /api/request-otp.php`
-  - Key: `otp-request:{ip}:{email}`
-- `POST /api/verify-otp.php`
-  - Key: `otp-verify:{ip}:{email}`
-- `POST /api/load-draft.php`
-  - Key: `load:{ip}:{email}`
-- `POST /api/save-draft.php`
-  - Key: `save:{ip}:{email}`
-- `POST /api/submit.php`
-  - Key: `submit:{ip}:{email}`
-- `POST /api/reset.php`
-  - Key: `reset:{ip}:{email}`
-- `POST /api/upload-preview.php`
-  - Key: `preview-upload:{ip}:{email}`
-- `POST /api/delete-upload.php`
-  - Key: `delete-upload:{ip}:{email}`
-
-Hinweis: Jeder Endpunkt hat einen eigenen Prefix und damit ein separates Limit-Fenster.
-
-## Kapazität bei aktivem Limiting
-
-Aktive Konfiguration (`config/app.local.php`):
-
-- `enabled = true`
-- `requests = 30`
-- `window_seconds = 300` (5 Minuten)
-
-Daraus ergibt sich pro Rate-Limit-Bucket (Key = `prefix + ip + email`):
-
-- 5 Minuten: `30` Requests
-- 1 Stunde: `360` Requests
-- 24 Stunden: `8.640` Requests
-
-Da aktuell 8 Endpunkte getrennte Buckets nutzen, ist der theoretische Gesamtwert über alle Endpunkte (gleiche IP + E-Mail) entsprechend:
-
-- 5 Minuten: `240` Requests
-- 1 Stunde: `2.880` Requests
-- 24 Stunden: `69.120` Requests
-
-## Verhalten bei Limitüberschreitung
-
-API antwortet mit HTTP `429` und einer Fehlermeldung (z. B. „Zu viele Anfragen“).
-
-## Betriebsaspekte
-
-- Viele Dateien in `storage/rate_limit/` sind normal.
-- Verzeichnis muss für den Webserver beschreibbar sein.
-- Löschen einzelner Dateien setzt das Limit für den betreffenden Key zurück.
-- Komplettes Leeren des Ordners setzt alle Limits zurück.
-
-## Tuning-Empfehlungen
-
-- Höherer Schutz: `requests` senken oder `window_seconds` erhöhen.
-- Weniger strenger Schutz: `requests` erhöhen oder `window_seconds` senken.
-- Bei aggressiven Bot-Wellen zuerst `submit` härter setzen (ggf. zukünftig endpoint-spezifische Limits einführen).
-
-## Bekannte Eigenschaften
-
-- Bei Dateisystemproblemen (Datei kann nicht geöffnet/gesperrt werden) erlaubt der Limiter aktuell die Anfrage (fail-open), um legitime Nutzer nicht zu blockieren.
-- NAT/Shared-IP kann mehrere legitime Nutzer unter derselben IP bündeln; durch Kombination mit E-Mail ist das Risiko reduziert.

+ 4 - 1
index.php

@@ -242,7 +242,10 @@ function renderField(array $field, string $addressDisclaimerText): void
         <form id="applicationForm" enctype="multipart/form-data" novalidate>
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
             <input type="hidden" id="applicationEmail" name="email" value="">
-            <input type="hidden" id="applicationWebsite" name="website" value="">
+            <div class="hp-field" aria-hidden="true">
+                <label for="applicationWebsite">Website</label>
+                <input id="applicationWebsite" type="text" name="website" autocomplete="off" tabindex="-1">
+            </div>
 
             <?php foreach ($steps as $index => $step): ?>
                 <section class="step hidden" data-step="<?= $index + 1 ?>">

+ 2 - 4
readme.md

@@ -46,7 +46,7 @@ Detaillierte Schritt-für-Schritt-Anleitung: `docs/initial_setup.md`
    - `cp config/app.sample.php config/app.local.php`
    - `cp config/mail.sample.php config/mail.local.php`
 6. Konfiguration anpassen:
-   - `config/app.local.php` (Admin-Credentials, Kontakt, Disclaimer, Retention, Rate Limit)
+   - `config/app.local.php` (Admin-Credentials, Kontakt, Disclaimer, Retention)
    - `config/mail.local.php` (Absender, Empfänger)
 7. Admin-Credential setzen:
    - Auf Server: `php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"`
@@ -63,8 +63,7 @@ Hinweis:
 ## Sicherheitshinweise
 
 - CSRF aktiv auf POST-Endpunkten.
-- Honeypot + Rate Limit aktiv.
-- Rate Limit fuer Tests deaktivierbar ueber `config/app.local.php -> rate_limit.enabled = false`.
+- Honeypot (Feld `website`) aktiv.
 - Uploads werden auf Typ, MIME und Größe geprüft.
 - Interne Ordner (`config`, `src`, `storage`, `docs`, `lib`) werden per `.htaccess` blockiert.
 
@@ -84,5 +83,4 @@ Lokale PHP-Laufzeit wird benötigt (CLI + Webserver), um Syntaxchecks/Tests ausz
 - `docs/initial_setup.md`
 - `docs/form_schema.md`
 - `docs/operations.md`
-- `docs/rate_limiting.md`
 - `docs/auth_integration.md`

+ 0 - 73
src/security/ratelimiter.php

@@ -1,73 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Security;
-
-use App\App\Bootstrap;
-
-final class RateLimiter
-{
-    private string $storageDir;
-    private bool $enabled;
-
-    public function __construct()
-    {
-        $app = Bootstrap::config('app');
-        $this->enabled = (bool) ($app['rate_limit']['enabled'] ?? true);
-        $this->storageDir = (string) ($app['storage']['rate_limit'] ?? Bootstrap::rootPath() . '/storage/rate_limit');
-
-        if ($this->enabled && !is_dir($this->storageDir)) {
-            mkdir($this->storageDir, 0775, true);
-        }
-    }
-
-    public function allow(string $key, int $limit, int $windowSeconds): bool
-    {
-        if (!$this->enabled) {
-            return true;
-        }
-
-        $hash = hash('sha256', $key);
-        $path = $this->storageDir . '/' . $hash . '.json';
-        $now = time();
-
-        $handle = fopen($path, 'c+');
-        if ($handle === false) {
-            return true;
-        }
-
-        try {
-            if (!flock($handle, LOCK_EX)) {
-                return true;
-            }
-
-            $contents = stream_get_contents($handle);
-            $timestamps = [];
-            if (is_string($contents) && $contents !== '') {
-                $decoded = json_decode($contents, true);
-                if (is_array($decoded)) {
-                    $timestamps = array_values(array_filter($decoded, static fn ($ts): bool => is_int($ts)));
-                }
-            }
-
-            $threshold = $now - $windowSeconds;
-            $timestamps = array_values(array_filter($timestamps, static fn (int $ts): bool => $ts >= $threshold));
-
-            if (count($timestamps) >= $limit) {
-                return false;
-            }
-
-            $timestamps[] = $now;
-
-            ftruncate($handle, 0);
-            rewind($handle);
-            fwrite($handle, json_encode($timestamps));
-
-            return true;
-        } finally {
-            flock($handle, LOCK_UN);
-            fclose($handle);
-        }
-    }
-}