Add Turnstile protection and harden export workflow
This commit is contained in:
+208
-69
@@ -1,10 +1,13 @@
|
||||
<?php
|
||||
include 'zefixAPI.php';
|
||||
declare(strict_types=1);
|
||||
|
||||
$taskDir = 'tasks';
|
||||
require_once __DIR__ . '/app.php';
|
||||
app_send_security_headers();
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
function renderPage(string $title, string $messageHtml, bool $isError = false): void
|
||||
function renderPage(string $title, string $messageHtml, bool $isError = false, int $httpStatus = 200): void
|
||||
{
|
||||
http_response_code($httpStatus);
|
||||
$logoPath = "img/silias-logo.png";
|
||||
|
||||
$statusClass = $isError
|
||||
@@ -19,17 +22,12 @@ echo '<!doctype html>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="css/bootstrap.css">
|
||||
<link rel="stylesheet" href="css/custom.css?v=1.6">
|
||||
<link rel="stylesheet" href="css/custom.css?v=2.2">
|
||||
<title>' . htmlspecialchars($title) . '</title>
|
||||
|
||||
<style>
|
||||
.brand-card { border-radius: 1rem; }
|
||||
|
||||
.brand-logo {
|
||||
height: 42px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.brand-box {
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
@@ -46,23 +44,18 @@ echo '<!doctype html>
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.logo-wrap{
|
||||
padding: .55rem .85rem; /* das ist der gewünschte “Rand” um das Logo */
|
||||
background: #fff;
|
||||
border: 1px solid #eef1f4;
|
||||
border-radius: .75rem;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.04);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-light">
|
||||
|
||||
<nav class="navbar navbar-light bg-white border-bottom">
|
||||
<nav class="navbar navbar-light site-header border-bottom py-2">
|
||||
<div class="container">
|
||||
<a class="navbar-brand mb-0 h1 text-decoration-none" href="index.php">Silias • ZEFIX Export</a>
|
||||
<a class="brand-lockup" href="index.php" aria-label="Silias Zefix Export – Startseite">
|
||||
<span class="brand-logo-crop" aria-hidden="true"><img src="img/silias-logo.png" alt=""></span>
|
||||
<span class="brand-divider" aria-hidden="true"></span>
|
||||
<span class="brand-product">Zefix Export<small>Ein kostenloses Silias-Tool</small></span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -93,10 +86,10 @@ echo '<!doctype html>
|
||||
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
|
||||
<div class="flex-shrink-0 logo-wrap">
|
||||
<img src="' . htmlspecialchars($logoPath) . '" alt="Silias Logo"
|
||||
class="brand-logo"
|
||||
onerror="this.style.display=\'none\';">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="brand-logo-crop d-block">
|
||||
<img src="' . htmlspecialchars($logoPath) . '" alt="Silias">
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ms-4">
|
||||
@@ -133,73 +126,219 @@ echo '<!doctype html>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="border-top bg-white">
|
||||
<div class="container py-3 text-center text-muted small">
|
||||
© ' . date('Y') . ' Silias KLG
|
||||
</div>
|
||||
</footer>
|
||||
<footer class="border-top bg-white">
|
||||
<div class="container py-3 text-center text-muted small">
|
||||
© ' . date('Y') . ' Silias KLG · <a href="datenschutz.php">Datenschutzerklärung</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>';
|
||||
}
|
||||
|
||||
/* ---------------- VALIDATION ---------------- */
|
||||
/* ---------------- REQUEST AND ABUSE PROTECTION ---------------- */
|
||||
|
||||
$email = isset($_POST['email']) ? trim($_POST['email']) : '';
|
||||
|
||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
renderPage("Ungültige E-Mail", "Bitte geben Sie eine gültige E-Mail-Adresse an.", true);
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
header('Allow: POST');
|
||||
renderPage('Methode nicht erlaubt', 'Bitte starten Sie den Export über das Formular auf der Startseite.', true, 405);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ---------------- TASK BUILD ---------------- */
|
||||
|
||||
$data = [];
|
||||
$data["maxEntries"] = 50;
|
||||
$data["offset"] = 0;
|
||||
$data["languageKey"] = "de";
|
||||
|
||||
if (!empty($_POST['firma'])) {
|
||||
$data["name"] = $_POST['firma'];
|
||||
// Bots commonly fill fields that are deliberately hidden from real visitors.
|
||||
$honeypot = isset($_POST['website']) && is_scalar($_POST['website']) ? trim((string)$_POST['website']) : '';
|
||||
if ($honeypot !== '') {
|
||||
renderPage(
|
||||
'Auftrag eingegangen',
|
||||
'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind.',
|
||||
false
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data["deletedFirms"] = isset($_POST['geloeschteRechtseinheiten']);
|
||||
$data["searchType"] = "exact";
|
||||
if (!app_turnstile_is_configured()) {
|
||||
renderPage('Export nicht verfügbar', 'Der Missbrauchsschutz ist noch nicht vollständig konfiguriert.', true, 503);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rechtsformen = $_POST['rechtsformen'] ?? [];
|
||||
$sitze = $_POST['sitze'] ?? [];
|
||||
$clientIp = app_client_ip();
|
||||
try {
|
||||
$ipLimit = app_rate_limit(
|
||||
'submit-ip',
|
||||
$clientIp,
|
||||
app_env_int('RATE_LIMIT_IP_MAX', 3, 1, 100),
|
||||
app_env_int('RATE_LIMIT_IP_WINDOW_SECONDS', 900, 60, 86400)
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Rate limit failure: ' . $exception->getMessage());
|
||||
renderPage('Export nicht verfügbar', 'Der Export kann momentan nicht sicher verarbeitet werden. Bitte versuchen Sie es später erneut.', true, 503);
|
||||
exit;
|
||||
}
|
||||
|
||||
$requests_to_do = [];
|
||||
if (!$ipLimit['allowed']) {
|
||||
header('Retry-After: ' . $ipLimit['retry_after']);
|
||||
renderPage('Zu viele Anfragen', 'Von dieser Verbindung wurden zu viele Aufträge gesendet. Bitte versuchen Sie es später erneut.', true, 429);
|
||||
exit;
|
||||
}
|
||||
|
||||
foreach ($rechtsformen as $rf) {
|
||||
$data["legalForms"] = [(int)$rf];
|
||||
$turnstileToken = isset($_POST['cf-turnstile-response']) && is_scalar($_POST['cf-turnstile-response'])
|
||||
? (string)$_POST['cf-turnstile-response']
|
||||
: '';
|
||||
$turnstileResult = app_verify_turnstile($turnstileToken, $clientIp);
|
||||
if (!$turnstileResult['success']) {
|
||||
renderPage('Bot-Prüfung fehlgeschlagen', 'Die Sicherheitsprüfung konnte nicht bestätigt werden. Bitte laden Sie die Startseite neu und versuchen Sie es erneut.', true, 403);
|
||||
exit;
|
||||
}
|
||||
|
||||
foreach ($sitze as $s) {
|
||||
$data["legalSeats"] = [(int)$s];
|
||||
$requests_to_do[] = $data;
|
||||
$formStarted = isset($_POST['form_started']) && is_scalar($_POST['form_started']) ? (string)$_POST['form_started'] : '';
|
||||
if (!app_validate_form_started_token($formStarted)) {
|
||||
renderPage('Überprüfung fehlgeschlagen', 'Das Formular ist abgelaufen oder wurde zu schnell übermittelt. Bitte laden Sie die Startseite neu.', true, 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ---------------- INPUT VALIDATION ---------------- */
|
||||
|
||||
$emailValue = $_POST['email'] ?? '';
|
||||
$email = is_scalar($emailValue) ? strtolower(trim((string)$emailValue)) : '';
|
||||
if ($email === '' || strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
renderPage('Ungültige E-Mail', 'Bitte geben Sie eine gültige E-Mail-Adresse an.', true, 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$companyValue = $_POST['firma'] ?? '';
|
||||
$companyName = is_scalar($companyValue) ? trim((string)$companyValue) : '';
|
||||
$companyNameLength = function_exists('mb_strlen') ? mb_strlen($companyName) : strlen($companyName);
|
||||
if ($companyNameLength > 200 || preg_match('//u', $companyName) !== 1 || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', $companyName)) {
|
||||
renderPage('Ungültiger Firmenname', 'Der Firmenname enthält ungültige Zeichen oder ist zu lang.', true, 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$maximumSeats = app_env_int('MAX_SEATS_PER_JOB', 500, 1, 5000);
|
||||
$maximumLegalForms = app_env_int('MAX_LEGAL_FORMS_PER_JOB', 50, 1, 200);
|
||||
$seats = app_normalize_positive_id_list($_POST['sitze'] ?? null, $maximumSeats);
|
||||
$legalForms = app_normalize_positive_id_list($_POST['rechtsformen'] ?? null, $maximumLegalForms);
|
||||
if ($seats === null || $legalForms === null) {
|
||||
renderPage('Ungültige Auswahl', 'Bitte wählen Sie eine zulässige Anzahl Orte und Rechtsformen aus.', true, 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
sort($seats, SORT_NUMERIC);
|
||||
sort($legalForms, SORT_NUMERIC);
|
||||
$requestCount = count($seats) * count($legalForms);
|
||||
$maximumRequests = app_env_int('MAX_REQUESTS_PER_JOB', 5000, 1, 50000);
|
||||
if ($requestCount > $maximumRequests) {
|
||||
renderPage(
|
||||
'Auswahl zu gross',
|
||||
'Diese Auswahl würde ' . number_format($requestCount, 0, ',', "'") . ' einzelne Abfragen erzeugen. Erlaubt sind maximal ' . number_format($maximumRequests, 0, ',', "'") . '. Bitte schränken Sie Orte oder Rechtsformen ein.',
|
||||
true,
|
||||
400
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$emailLimit = app_rate_limit(
|
||||
'submit-email',
|
||||
$email,
|
||||
app_env_int('RATE_LIMIT_EMAIL_MAX', 5, 1, 100),
|
||||
app_env_int('RATE_LIMIT_EMAIL_WINDOW_SECONDS', 86400, 300, 604800)
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Rate limit failure: ' . $exception->getMessage());
|
||||
renderPage('Export nicht verfügbar', 'Der Export kann momentan nicht sicher verarbeitet werden. Bitte versuchen Sie es später erneut.', true, 503);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$emailLimit['allowed']) {
|
||||
header('Retry-After: ' . $emailLimit['retry_after']);
|
||||
renderPage('Zu viele Anfragen', 'Für diese E-Mail-Adresse wurden heute bereits mehrere Exporte angefordert.', true, 429);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ---------------- TASK BUILD AND ATOMIC QUEUE WRITE ---------------- */
|
||||
|
||||
$baseRequest = [
|
||||
'maxEntries' => 50,
|
||||
'offset' => 0,
|
||||
'languageKey' => 'de',
|
||||
'deletedFirms' => isset($_POST['geloeschteRechtseinheiten']),
|
||||
'searchType' => 'exact',
|
||||
];
|
||||
if ($companyName !== '') {
|
||||
$baseRequest['name'] = $companyName;
|
||||
}
|
||||
|
||||
$requestsToDo = [];
|
||||
foreach ($legalForms as $legalForm) {
|
||||
foreach ($seats as $seat) {
|
||||
$request = $baseRequest;
|
||||
$request['legalForms'] = [$legalForm];
|
||||
$request['legalSeats'] = [$seat];
|
||||
$requestsToDo[] = $request;
|
||||
}
|
||||
}
|
||||
|
||||
$taskdata = [
|
||||
'requests' => $requests_to_do,
|
||||
'email' => $email
|
||||
];
|
||||
$fingerprint = hash('sha256', json_encode([
|
||||
'email' => $email,
|
||||
'company' => $companyName,
|
||||
'deleted' => $baseRequest['deletedFirms'],
|
||||
'seats' => $seats,
|
||||
'legalForms' => $legalForms,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
if (!is_dir($taskDir)) {
|
||||
mkdir($taskDir, 0755, true);
|
||||
}
|
||||
try {
|
||||
$taskDirectory = app_task_dir();
|
||||
$queueLock = fopen(app_private_dir() . DIRECTORY_SEPARATOR . 'queue.lock', 'c+');
|
||||
if ($queueLock === false || !flock($queueLock, LOCK_EX)) {
|
||||
throw new RuntimeException('Queue lock could not be acquired.');
|
||||
}
|
||||
|
||||
$taskfilename = $taskDir . '/' . time() . '-' . bin2hex(random_bytes(4)) . '.json';
|
||||
try {
|
||||
$taskFiles = glob($taskDirectory . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||
$maximumQueueSize = app_env_int('MAX_QUEUE_SIZE', 20, 1, 1000);
|
||||
if (count($taskFiles) >= $maximumQueueSize) {
|
||||
renderPage('Warteschlange voll', 'Momentan werden bereits viele Exporte verarbeitet. Bitte versuchen Sie es später erneut.', true, 503);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (file_put_contents($taskfilename, json_encode($taskdata))) {
|
||||
foreach ($taskFiles as $existingTaskFile) {
|
||||
$existing = json_decode((string)file_get_contents($existingTaskFile), true);
|
||||
if (is_array($existing) && hash_equals((string)($existing['fingerprint'] ?? ''), $fingerprint)) {
|
||||
renderPage(
|
||||
'Auftrag bereits vorhanden',
|
||||
'Ein identischer Auftrag wartet bereits auf die Verarbeitung. Sie erhalten den Download-Link per E-Mail.',
|
||||
false
|
||||
);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$taskData = [
|
||||
'version' => 2,
|
||||
'createdAt' => time(),
|
||||
'requests' => $requestsToDo,
|
||||
'email' => $email,
|
||||
'fingerprint' => $fingerprint,
|
||||
'downloadToken' => bin2hex(random_bytes(16)),
|
||||
];
|
||||
$taskFilename = $taskDirectory . DIRECTORY_SEPARATOR . time() . '-' . bin2hex(random_bytes(16)) . '.json';
|
||||
$temporaryFilename = $taskFilename . '.tmp';
|
||||
$written = file_put_contents($temporaryFilename, json_encode($taskData, JSON_THROW_ON_ERROR), LOCK_EX);
|
||||
if ($written === false || !rename($temporaryFilename, $taskFilename)) {
|
||||
@unlink($temporaryFilename);
|
||||
throw new RuntimeException('Task file could not be written.');
|
||||
}
|
||||
@chmod($taskFilename, 0600);
|
||||
} finally {
|
||||
flock($queueLock, LOCK_UN);
|
||||
fclose($queueLock);
|
||||
}
|
||||
|
||||
renderPage(
|
||||
"Auftrag eingegangen",
|
||||
"Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind. Dies kann je nach Datenmenge lange dauern.",
|
||||
'Auftrag eingegangen',
|
||||
'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind. Dies kann je nach Datenmenge länger dauern.',
|
||||
false
|
||||
);
|
||||
|
||||
} else {
|
||||
|
||||
renderPage("Fehler", "Ein Fehler ist aufgetreten.", true);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Task creation failed: ' . $exception->getMessage());
|
||||
renderPage('Fehler', 'Der Auftrag konnte momentan nicht gespeichert werden. Bitte versuchen Sie es später erneut.', true, 503);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user