Files
tsv08kulmbach-website/app/actions/membership-submit.php
fs e7801ca6ee Spam-Schutz: Rate-Limiting, Link-Heuristik & Spam-Logging
Mehrschichtiger Formular-Schutz ohne externe Dienste, ergänzend zu Honeypot
und HMAC-Time-Trap:
- rate_limit_ok(): pro IP+Route (5/10 min), Tages-Cap (100/Tag), Token-Replay
- client_ip() / log_spam() (abgewiesene Versuche -> storage/logs/spam.log)
- Honeypot-Feld website -> company_url umbenannt (contact-/membership-form)
- Ratelimit-Hinweis in form.js
- storage/ratelimit/ (gitignored bis auf .gitkeep)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGzc6GhWhmLJt1jC2q1SRZ
2026-06-20 20:41:50 +02:00

158 lines
5.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten).
* Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung
* an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt).
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…)
* oder JSON bei fetch (form.js).
*/
use PHPMailer\PHPMailer\PHPMailer;
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'error' => $error ?: null]);
} else {
header('Location: ' . url('mitglied-werden') . ($ok ? '?sent=1' : '?error=' . $error) . '#anmeldung-fussball', true, 303);
}
exit;
};
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
$route = 'mitglied-werden';
$token = $field('ft');
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') {
log_spam($route, 'honeypot');
$respond(true);
}
if (!form_token_valid($token)) {
log_spam($route, 'token');
$respond(true);
}
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen).
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// --- Eingaben ---
$firstName = $field('first_name');
$lastName = $field('last_name');
$birthDate = $field('birth_date');
$phone = $field('phone');
$street = $field('street');
$zip = $field('zip');
$city = $field('city');
$email = $field('email');
$feeClass = $field('fee_class');
// Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten.
$data = json_load('mitglied-werden');
$feeOptions = array_map(
static fn (array $r): string => $r['label'] . ' — ' . $r['price'],
$data['fussball']['fees']['rows'] ?? []
);
// --- Validierung (Whitelist-Prinzip) ---
$valid = $firstName !== '' && mb_strlen($firstName) <= 100
&& $lastName !== '' && mb_strlen($lastName) <= 100
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate) === 1
&& $phone !== '' && mb_strlen($phone) <= 50
&& $street !== '' && mb_strlen($street) <= 200
&& $zip !== '' && mb_strlen($zip) <= 10
&& $city !== '' && mb_strlen($city) <= 100
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
&& in_array($feeClass, $feeOptions, true)
&& ($_POST['statute'] ?? '') === '1'
&& ($_POST['privacy'] ?? '') === '1'
&& !preg_match('/[\r\n]/', $firstName . $lastName . $city . $street);
if (!$valid) {
$respond(false, 'validation');
}
// --- Aufkommens-Heuristiken (still abweisen) ---
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
if (!rate_limit_ok('daily', 100, 86400)) {
log_spam($route, 'daily-cap');
$respond(true);
}
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
log_spam($route, 'replay');
$respond(true);
}
// --- Versand ---
$smtp = config('smtp');
$body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n"
. "Vorname: {$firstName}\n"
. "Nachname: {$lastName}\n"
. "Geboren am: {$birthDate}\n"
. "Adresse: {$street}, {$zip} {$city}\n"
. "Telefon: {$phone}\n"
. "E-Mail: {$email}\n"
. "Beitragsklasse: {$feeClass}\n"
. str_repeat('-', 40) . "\n"
. "Satzung anerkannt: ja\n"
. "Datenschutz: ja\n"
. 'Eingegangen: ' . date('d.m.Y H:i') . "\n\n"
. "Hinweis: Bankdaten werden online nicht erhoben Beitrittserklärung\n"
. "per Post zur Unterschrift zusenden.\n";
$buildMailer = static function () use ($smtp): PHPMailer {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = $smtp['username'];
$mail->Password = $smtp['password'];
$mail->CharSet = PHPMailer::CHARSET_UTF8;
return $mail;
};
// Mail 1 (intern) — erfolgskritisch.
try {
$mail = $buildMailer();
$mail->setFrom($smtp['from'], $smtp['from_name']);
$mail->addAddress($smtp['to']);
$mail->addReplyTo($email, $firstName . ' ' . $lastName);
$mail->Subject = 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName;
$mail->Body = $body;
$mail->send();
} catch (Throwable $e) {
error_log('[' . date('c') . '] Mitgliedsantrag-Versand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
$respond(false, 'mail');
}
// Mail 2 (Eingangsbestätigung an Antragsteller) — optional, Fehler nicht an Besucher leaken.
$welcome = $data['welcome_mail'] ?? null;
if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) {
try {
$confirm = $buildMailer();
$confirm->setFrom($smtp['from'], $smtp['from_name']);
$confirm->addAddress($email, $firstName . ' ' . $lastName);
$confirm->Subject = $welcome['subject'];
$confirm->Body = $welcome['body'];
$confirm->send();
} catch (Throwable $e) {
error_log('[' . date('c') . '] Bestätigungsmail fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
}
}
$respond(true);