diff --git a/fp_altcha_spamschutz/src/Handler/ValidationHandler.php b/fp_altcha_spamschutz/src/Handler/ValidationHandler.php index f4bd4c0..329b295 100644 --- a/fp_altcha_spamschutz/src/Handler/ValidationHandler.php +++ b/fp_altcha_spamschutz/src/Handler/ValidationHandler.php @@ -27,6 +27,11 @@ use Plugin\fp_altcha_spamschutz\src\Service\AltchaService; * Verhalten zu verlassen, wird hier bewusst derselbe frühe, hook-unabhängige Ansatz wie bei der * Newsletter-Anmeldung verwendet: direkte Prüfung von $_POST, noch bevor JTL-Shop das Formular * verarbeitet (siehe guardContactSubmission()). + * + * Zusätzlich zur ALTCHA-Prüfung gibt es zwei weitere, unabhängige Ablehnungsgründe (siehe + * matchesBlockedPattern() und isFlaggedByHoneypot()): beide fangen gezielt Bots ab, die zwar + * JavaScript ausführen und die Rechenaufgabe lösen können, sich aber an einem bekannten + * Namensmuster oder einer bereits einschlägig aufgefallenen IP-Adresse erkennen lassen. */ class ValidationHandler { @@ -47,10 +52,16 @@ class ValidationHandler return; } - if ($this->altchaService->verifyPost()) { + $blockedByAltcha = !$this->altchaService->verifyPost(); + $blockedByPattern = $this->matchesBlockedPattern(); + $blockedByHoneypot = $this->isFlaggedByHoneypot(); + + if (!$blockedByAltcha && !$blockedByPattern && !$blockedByHoneypot) { return; } + $this->logRejection('Registrierung', $blockedByAltcha, $blockedByPattern, $blockedByHoneypot); + if (isset($args['fehlendeAngaben']) && \is_array($args['fehlendeAngaben'])) { $args['fehlendeAngaben'][] = 'Sicherheitsprüfung'; } @@ -87,10 +98,16 @@ class ValidationHandler return; } - if ($this->altchaService->verifyPost()) { + $blockedByAltcha = !$this->altchaService->verifyPost(); + $blockedByPattern = $this->matchesBlockedPattern(); + $blockedByHoneypot = $this->isFlaggedByHoneypot(); + + if (!$blockedByAltcha && !$blockedByPattern && !$blockedByHoneypot) { return; } + $this->logRejection('Newsletter-Anmeldung', $blockedByAltcha, $blockedByPattern, $blockedByHoneypot); + // Feld entfernen, damit JTL-Shop die Anmeldung nicht als "abonnieren"-Request erkennt // und stattdessen nur das (leere) Formular erneut anzeigt. unset($_POST['abonnieren']); @@ -123,10 +140,16 @@ class ValidationHandler return; } - if ($this->altchaService->verifyPost()) { + $blockedByAltcha = !$this->altchaService->verifyPost(); + $blockedByPattern = $this->matchesBlockedPattern(); + $blockedByHoneypot = $this->isFlaggedByHoneypot(); + + if (!$blockedByAltcha && !$blockedByPattern && !$blockedByHoneypot) { return; } + $this->logRejection('Kontaktformular', $blockedByAltcha, $blockedByPattern, $blockedByHoneypot); + // Pflichtfeld leeren, damit JTL-Shops eigene Prüfung ("Pflichtfeld nicht ausgefüllt") // die Anfrage abweist -- der Besucher bekommt die normale, vertraute Fehlermeldung. unset($_POST['nachricht']); @@ -165,4 +188,103 @@ class ValidationHandler } } } + + /** + * Prüft Vorname, Nachname, Ort und Straße aus dem aktuellen POST-Request gegen die in den + * Plugin-Einstellungen hinterlegte Liste bekannter Bot-Namensmuster (z. B. "Test", "Test + * User"). Verglichen wird der komplette, getrimmte Feldwert ohne Beachtung von Groß-/ + * Kleinschreibung -- absichtlich kein Teilstring-Vergleich, damit z. B. eine echte Straße + * oder ein echter Ort, der zufällig den Begriff enthält, nicht fälschlich blockiert wird. + */ + private function matchesBlockedPattern(): bool + { + $patterns = $this->altchaService->getNameBlocklist(); + if ($patterns === []) { + return false; + } + + foreach (['vorname', 'nachname', 'ort', 'strasse'] as $field) { + $value = isset($_POST[$field]) && \is_string($_POST[$field]) ? \trim($_POST[$field]) : ''; + if ($value === '') { + continue; + } + + foreach ($patterns as $pattern) { + if (\mb_strtolower($value) === \mb_strtolower($pattern)) { + return true; + } + } + } + + return false; + } + + /** + * Fragt Project Honey Pot (http:BL) fuer die IP-Adresse des aktuellen Besuchers ab, sofern + * in den Einstellungen ein Zugangsschluessel hinterlegt ist. http:BL unterstuetzt nur IPv4; + * bei IPv6-Adressen oder wenn die Abfrage fehlschlaegt, wird sicherheitshalber NICHT + * blockiert (lieber ein Bot mehr durchlassen als echte Kunden faelschlich abweisen). + * + * Wichtig: es wird bewusst nur $_SERVER['REMOTE_ADDR'] verwendet, nicht z. B. + * X-Forwarded-For -- dieser Header liesse sich von einem Bot beliebig faelschen und wuerde + * die Pruefung wirkungslos machen. Laeuft der Shop hinter einem Reverse-Proxy, der die echte + * Besucher-IP nicht in REMOTE_ADDR durchreicht, liefert diese Pruefung keine sinnvollen + * Ergebnisse mehr. + */ + private function isFlaggedByHoneypot(): bool + { + if (!$this->altchaService->isHoneypotEnabled()) { + return false; + } + + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + if (!\is_string($ip) || \filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) === false) { + return false; + } + + $reversedIp = \implode('.', \array_reverse(\explode('.', $ip))); + $accessKey = $this->altchaService->getHoneypotApiKey(); + $query = $accessKey . '.' . $reversedIp . '.dnsbl.httpbl.org'; + + $records = @\dns_get_record($query, \DNS_A); + if (!\is_array($records) || $records === [] || !isset($records[0]['ip'])) { + // Keine Antwort/kein Eintrag -- IP ist bei http:BL nicht (mehr) bekannt. + return false; + } + + $parts = \explode('.', (string) $records[0]['ip']); + if (\count($parts) !== 4 || $parts[0] !== '127') { + // Unerwartetes Antwortformat -- sicherheitshalber nicht blocken. + return false; + } + + $threatScore = (int) $parts[2]; + $type = (int) $parts[3]; + + if ($type === 0) { + // Typ 0 = ausschliesslich als Suchmaschine gelistet, kein Ablehnungsgrund. + return false; + } + + return $threatScore >= $this->altchaService->getHoneypotThreshold(); + } + + private function logRejection( + string $formular, + bool $blockedByAltcha, + bool $blockedByPattern, + bool $blockedByHoneypot + ): void { + if (!$this->altchaService->isDebug()) { + return; + } + + error_log(\sprintf( + '[fp_altcha_spamschutz] %s abgelehnt (altcha=%s, namensmuster=%s, honeypot=%s)', + $formular, + $blockedByAltcha ? 'ja' : 'nein', + $blockedByPattern ? 'ja' : 'nein', + $blockedByHoneypot ? 'ja' : 'nein' + )); + } }