15 Commits
Author SHA1 Message Date
JensFalk 332e30bf57 v1.4.0: Angriffsprotokoll-Einstellungen, Bestaetigungstext geaendert (erneuter Versuch) 2026-09-22 10:56:42 +02:00
JensFalk 96402e8005 log.php: neue, token-geschuetzte Ansicht fuer das Angriffsprotokoll hinzugefuegt 2026-09-22 10:54:20 +02:00
JensFalk 6b2f05b96b fp-altcha.js: Fallback-Text an neuen Bestaetigungstext angepasst 2026-09-22 10:53:08 +02:00
JensFalk 5b11353bc1 TemplateHandler: Text geaendert, Platzierung im input-group Newsletter-Feld korrigiert 2026-09-22 10:52:02 +02:00
JensFalk b7dfe8f48b ValidationHandler: Ablehnungen zusaetzlich im Angriffsprotokoll speichern 2026-09-22 10:50:23 +02:00
JensFalk 0c60f3d15e AltchaService: Angriffsprotokoll (Datei-Log + Zugriffsschluessel) hinzugefuegt 2026-09-22 10:47:30 +02:00
JensFalk 49308d311b Bootstrap: Zugriffsschluessel fuer Angriffsprotokoll fruehzeitig erzeugen 2026-09-22 10:45:10 +02:00
JensFalk 3c1128ca4d AltchaService: Config-Getter fuer Namensmuster-Filter und Project Honey Pot 2026-09-22 09:34:00 +02:00
JensFalk 17f5c7b98f ValidationHandler: Namensmuster-Filter und Project Honey Pot als zusaetzliche Ablehnungsgruende 2026-09-22 09:33:24 +02:00
JensFalk 2dbf11753a info.xml: Namensmuster-Filter und Project Honey Pot Einstellungen, Version 1.3.0 2026-09-22 09:32:34 +02:00
JensFalk 1e6fb0be5b README.md aktualisiert 2026-09-21 21:25:41 +02:00
JensFalk bc27582a0d LICENSE hinzugefügt 2026-09-21 21:23:11 +02:00
JensFalk fbfc194d2e README: Pluginname aktualisiert (falk_plus statt falk.plus) 2026-09-21 20:52:29 +02:00
JensFalk e3194d4b2f info.xml: Pluginname ohne Punkt (falk_plus statt falk.plus) wegen Namenskonvention, Version 1.2.1 2026-09-21 20:51:38 +02:00
JensFalk 1d5f6df674 README: Umbenennung, Umlaute, Download-Hinweise, Kunden-/Ticketbezug entfernt 2026-09-21 20:34:33 +02:00
9 changed files with 485 additions and 38 deletions
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 JensFalk
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+3 -25
View File
@@ -1,27 +1,5 @@
# JTL-Shop ALTCHA Spamschutz
# falk_plus ALTCHA Spam- und Botschutz
Selbst gehostetes, quelloffenes Spamschutz-Plugin (ALTCHA Proof-of-Work, MIT-Lizenz) fuer JTL-Shop 5 -- schuetzt Registrierung und Newsletter-Anmeldung, ohne Daten an Dritte weiterzugeben. Bewusst ohne Google reCAPTCHA oder aehnliche externe Dienste und ohne Umleitung des Datenverkehrs ueber Cloudflare oder vergleichbare Drittanbieter -- die Pruefung laeuft vollstaendig auf dem eigenen Server.
weiteres siehe im [Guide](https://guide.falk.plus/doku.php?id=jtl-shop:altcha-spamschutz:start)
## Enthaelt
- `fp_altcha_spamschutz/` -- komplettes, installierbares JTL-Shop-5-Plugin (Plugin-ID `fp_altcha_spamschutz`)
- `Bootstrap.php` -- Hook-Registrierung (EventDispatcher)
- `info.xml` -- Plugin-Manifest / Einstellungen
- `src/Service/AltchaService.php` -- Challenge-Erzeugung & Verifikation (ALTCHA V1)
- `src/Handler/TemplateHandler.php` -- Widget-Einbindung ins Formular-HTML
- `src/Handler/ValidationHandler.php` -- Pruefung bei Registrierung & Newsletter-Anmeldung
- `assets/fp-altcha.js` -- client-seitiger Proof-of-Work-Loeser
Die per Chat gelieferte, installierbare Plugin-ZIP enthaelt zusaetzlich die unveraendert vendorierte ALTCHA-PHP-Bibliothek ([altcha-org/altcha-lib-php](https://github.com/altcha-org/altcha-lib-php), MIT-Lizenz) unter `src/Vendor/AltchaOrg/Altcha/V1/`. Um Drittanbieter-Code hier nicht zu duplizieren, ist sie in diesem Repo nicht enthalten.
## Hintergrund
Anlass war ein Bot-Problem bei einem Kunden: Ueber Wochen liefen massenhaft Fake-Registrierungen und Newsletter-Anmeldungen mit immer demselben Muster auf ("Test"/"Test User" in Vorname/Nachname/Ort/Strasse, aber echte, unterschiedliche Fremd-E-Mail-Adressen). Rate-Limiting und andere getestete Plugins konnten das Problem nicht zuverlaessig loesen, da deren Pruefungen an anderer Stelle ansetzen (z. B. an Kommentar-/Nachrichtenfeldern, die weder das Registrierungs- noch das Newsletter-Formular besitzen). Dieses Plugin prueft stattdessen direkt beim Absenden von Registrierung und Newsletter-Anmeldung selbst -- unabhaengig davon, welche Felder das Formular sonst enthaelt -- und kann bestehende Loesungen vollstaendig ersetzen.
## Dokumentation
Ausfuehrliche Installations-, Test- und Hintergrundinfos im Guide: [jtl-shop:altcha-spamschutz:start](https://guide.falk.plus/doku.php?id=jtl-shop:altcha-spamschutz:start)
## Feedback
Fragen, Bugs oder Verbesserungsvorschlaege bitte als [Issue in diesem Repo](https://vw.falk.plus/JensFalk/JTL-Shop-ALTCHA-Spamschutz/issues) melden.
Fragen, Bugs oder Verbesserungsvorschläge bitte als [Issue in diesem Repo](https://vw.falk.plus/JensFalk/JTL-Shop-ALTCHA-Spamschutz/issues) melden.
+7
View File
@@ -66,6 +66,13 @@ class Bootstrap extends Bootstrapper
$templateHandler = new TemplateHandler($plugin, $altchaService);
$validationHandler = new ValidationHandler($altchaService);
// Zugriffsschluessel fuer die Protokoll-Ansicht (log.php) frueh erzeugen, sofern das
// Angriffsprotokoll aktiv ist -- so liegt die Datei mit dem Schluessel bereit, sobald
// der Admin sie das erste Mal braucht, statt erst nach der ersten abgelehnten Anfrage.
if ($altchaService->isAttackLogEnabled()) {
$altchaService->getLogViewToken();
}
// Früheste mögliche Prüfung der Newsletter-Anmeldung: läuft direkt hier, noch
// bevor JTL-Shop das $_POST verarbeitet. Siehe ValidationHandler::guardNewsletterSubmission().
$validationHandler->guardNewsletterSubmission();
+1 -1
View File
@@ -93,7 +93,7 @@
if (status) {
status.innerHTML = '<i class="fa fa-check" aria-hidden="true"></i> ' +
(container.getAttribute('data-label-verified') || 'Sicherheitsprüfung<br>bestanden');
(container.getAttribute('data-label-verified') || 'Ja, ich bin ein Mensch');
}
} catch (e) {
container.setAttribute('data-fp-altcha-state', 'failed');
+49 -3
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<jtlshopplugin>
<Name>falk.plus ALTCHA Spam- und Botschutz</Name>
<Name>falk_plus ALTCHA Spam- und Botschutz</Name>
<Description>Selbst gehosteter, quelloffener Spam- und Botschutz auf Basis des ALTCHA Proof-of-Work-Verfahrens (MIT-Lizenz). Schützt Registrierung, Newsletter-Anmeldung und Kontaktformular vor Spam-Bots und automatisierten Fake-Anmeldungen, ohne Datenweitergabe an Dritte -- bewusst ohne Google reCAPTCHA und ohne Umleitung über Cloudflare oder ähnliche Dienste. Für beliebige JTL-Shop-5-Installationen geeignet.</Description>
<Author>falk.plus</Author>
<URL>https://falk.plus</URL>
@@ -8,7 +8,7 @@
<XMLVersion>101</XMLVersion>
<MinShopVersion>5.2.0</MinShopVersion>
<CreateDate>2026-09-21</CreateDate>
<Version>1.2.0</Version>
<Version>1.4.0</Version>
<Install>
<FlushTags>CACHING_GROUP_TEMPLATE</FlushTags>
<!-- Hooks werden nicht über XML, sondern modern per EventDispatcher in Bootstrap.php registriert. -->
@@ -20,7 +20,7 @@
<Name>fp_altcha_preparing</Name>
</Variable>
<Variable>
<VariableLocalized iso="GER">Sicherheitsprüfung bestanden</VariableLocalized>
<VariableLocalized iso="GER">Ja, ich bin ein Mensch</VariableLocalized>
<VariableLocalized iso="ENG">Security check passed</VariableLocalized>
<Description></Description>
<Name>fp_altcha_verified</Name>
@@ -68,6 +68,52 @@
<ValueName>fp_altcha_expiry_seconds</ValueName>
</Setting>
</Settingslink>
<Settingslink sort="5">
<Name>Namensmuster-Filter</Name>
<Setting type="text" initialValue="Test,Test User" sort="0" conf="Y">
<Name>Gesperrte Namensmuster</Name>
<Description>Kommagetrennte Liste von Begriffen. Eine Anfrage wird zusätzlich abgelehnt, wenn Vorname, Nachname, Ort oder Straße exakt (ohne Beachtung von Groß-/Kleinschreibung) einem dieser Begriffe entspricht -- unabhängig vom Ergebnis der Sicherheitsprüfung. Damit lassen sich auch Bots abfangen, die die Sicherheitsprüfung technisch lösen können. Feld leeren, um diese zusätzliche Prüfung zu deaktivieren. Bei einem neuen/anderen erkannten Muster hier einfach ergänzen, z. B. Test,Test User,Testkunde.</Description>
<ValueName>fp_altcha_name_blocklist</ValueName>
</Setting>
</Settingslink>
<Settingslink sort="8">
<Name>Project Honey Pot</Name>
<Setting type="text" initialValue="" sort="0" conf="Y">
<Name>http:BL Zugangsschlüssel</Name>
<Description>Eigener Zugangsschlüssel von projecthoneypot.org für die http:BL-Reputationsabfrage der Besucher-IP. Leer lassen, um diese zusätzliche Prüfung zu deaktivieren. Ein kostenloser Schlüssel lässt sich unter projecthoneypot.org anlegen.</Description>
<ValueName>fp_altcha_honeypot_key</ValueName>
</Setting>
<Setting type="selectbox" initialValue="50" sort="10" conf="Y">
<Name>Mindest-Bedrohungsstufe für Ablehnung</Name>
<Description>Ab welcher von http:BL gemeldeten Bedrohungsstufe (0-255) eine Anfrage zusätzlich abgelehnt wird. Niedrigerer Wert = strenger.</Description>
<ValueName>fp_altcha_honeypot_threshold</ValueName>
<SelectboxOptions>
<Option value="0" sort="1"><![CDATA[Sehr streng (0)]]></Option>
<Option value="25" sort="2"><![CDATA[Streng (25)]]></Option>
<Option value="50" sort="3"><![CDATA[Mittel (50, empfohlen)]]></Option>
<Option value="75" sort="4"><![CDATA[Locker (75)]]></Option>
</SelectboxOptions>
</Setting>
</Settingslink>
<Settingslink sort="9">
<Name>Angriffsprotokoll</Name>
<Setting type="checkbox" initialValue="on" sort="0" conf="Y">
<Name>Angriffsprotokoll speichern</Name>
<Description>Hält abgelehnte Anfragen (Sicherheitsprüfung fehlgeschlagen, Namensmuster oder Project Honey Pot) fortlaufend in einer eigenen Protokolldatei im Plugin-Verzeichnis fest, unabhängig vom Debug-Logging. Einsehbar über eine eigene, durch einen Zugriffsschlüssel geschützte Seite: https://ihre-domain/plugins/fp_altcha_spamschutz/log.php?key=IHR-SCHLUESSEL. Der Zugriffsschlüssel wird automatisch erzeugt und steht in der Datei data/fp_altcha_log_token.php im Plugin-Verzeichnis (einmalig per FTP/Datei-Manager auslesen).</Description>
<ValueName>fp_altcha_attack_log</ValueName>
</Setting>
<Setting type="selectbox" initialValue="300" sort="10" conf="Y">
<Name>Maximale Anzahl gespeicherter Einträge</Name>
<Description>Wie viele der zuletzt abgelehnten Anfragen aufbewahrt werden, bevor die ältesten automatisch entfernt werden.</Description>
<ValueName>fp_altcha_attack_log_max</ValueName>
<SelectboxOptions>
<Option value="100" sort="1"><![CDATA[100]]></Option>
<Option value="300" sort="2"><![CDATA[300 (empfohlen)]]></Option>
<Option value="1000" sort="3"><![CDATA[1.000]]></Option>
<Option value="3000" sort="4"><![CDATA[3.000]]></Option>
</SelectboxOptions>
</Setting>
</Settingslink>
<Settingslink sort="10">
<Name>Formulare</Name>
<Setting type="checkbox" initialValue="on" sort="0" conf="Y">
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Eigenständige, schlanke Protokoll-Ansicht für abgelehnte Anfragen (Angriffsprotokoll).
*
* Bewusst OHNE Einbindung des JTL-Shop-Bootstraps: diese Datei liegt im öffentlich erreichbaren
* Plugin-Verzeichnis (genau wie assets/fp-altcha.js) und wird direkt vom Webserver ausgeführt,
* nicht über die Shop-Routen. Der Zugriff wird stattdessen über einen eigenen, zufällig
* erzeugten Zugriffsschlüssel abgesichert (siehe data/fp_altcha_log_token.php, automatisch
* erzeugt nach demselben Muster wie der HMAC-Geheimschlüssel in src/Service/AltchaService.php).
*
* Aufruf: https://ihre-domain/plugins/fp_altcha_spamschutz/log.php?key=<Zugriffsschluessel>
*
* Den aktuellen Zugriffsschlüssel finden Sie in der Datei data/fp_altcha_log_token.php im
* Plugin-Verzeichnis (einmalig per FTP/Datei-Manager auslesen). Die Datei wird automatisch
* angelegt, sobald das Angriffsprotokoll in den Plugin-Einstellungen aktiviert ist.
*/
$dataDir = __DIR__ . '/data';
$tokenFile = $dataDir . '/fp_altcha_log_token.php';
$logFile = $dataDir . '/fp_altcha_attacklog.jsonl';
$expectedToken = \is_file($tokenFile) ? @include $tokenFile : null;
$providedToken = isset($_GET['key']) && \is_string($_GET['key']) ? $_GET['key'] : '';
$tokenIsValid = \is_string($expectedToken)
&& $expectedToken !== ''
&& $providedToken !== ''
&& \hash_equals($expectedToken, $providedToken);
if (!$tokenIsValid) {
\http_response_code(404);
echo 'Not found';
exit;
}
\header('Content-Type: text/html; charset=UTF-8');
\header('X-Robots-Tag: noindex, nofollow');
$entries = [];
if (\is_file($logFile)) {
$lines = @\file($logFile, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES) ?: [];
foreach (\array_reverse($lines) as $line) {
$decoded = \json_decode($line, true);
if (\is_array($decoded)) {
$entries[] = $decoded;
}
}
}
$anzahl = \count($entries);
echo '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">'
. '<title>fp_altcha_spamschutz &ndash; Angriffsprotokoll</title>'
. '<style>'
. 'body{font-family:sans-serif;margin:2rem;background:#f7f7f7;color:#222;}'
. 'h1{font-size:1.2rem;}'
. 'table{border-collapse:collapse;width:100%;background:#fff;}'
. 'th,td{border:1px solid #ddd;padding:.4rem .6rem;font-size:.9rem;text-align:left;}'
. 'th{background:#eee;}'
. '.ja{color:#a30000;font-weight:bold;}'
. '.nein{color:#2a7d2a;}'
. '</style></head><body>';
echo '<h1>fp_altcha_spamschutz &ndash; Angriffsprotokoll ('
. $anzahl . ' Eintr&auml;ge, neueste zuerst)</h1>';
if ($anzahl === 0) {
echo '<p>Noch keine abgelehnten Anfragen protokolliert.</p>';
} else {
echo '<table><tr><th>Zeit</th><th>Formular</th><th>IP</th><th>Sicherheitspr&uuml;fung</th>'
. '<th>Namensmuster</th><th>Honeypot</th></tr>';
foreach ($entries as $entry) {
$zeit = \htmlspecialchars((string) ($entry['zeit'] ?? ''), \ENT_QUOTES, 'UTF-8');
$formular = \htmlspecialchars((string) ($entry['formular'] ?? ''), \ENT_QUOTES, 'UTF-8');
$ip = \htmlspecialchars((string) ($entry['ip'] ?? ''), \ENT_QUOTES, 'UTF-8');
$pruefung = \htmlspecialchars((string) ($entry['sicherheitspruefung'] ?? ''), \ENT_QUOTES, 'UTF-8');
$muster = !empty($entry['namensmuster']) ? '<span class="ja">ja</span>' : '<span class="nein">nein</span>';
$honeypot = !empty($entry['honeypot']) ? '<span class="ja">ja</span>' : '<span class="nein">nein</span>';
echo '<tr>'
. '<td>' . $zeit . '</td>'
. '<td>' . $formular . '</td>'
. '<td>' . $ip . '</td>'
. '<td>' . $pruefung . '</td>'
. '<td>' . $muster . '</td>'
. '<td>' . $honeypot . '</td>'
. '</tr>';
}
echo '</table>';
}
echo '</body></html>';
@@ -146,10 +146,7 @@ class TemplateHandler
$labelPreparing = $isGerman ? 'Sicherheitsprüfung wird vorbereitet …' : 'Preparing security check …';
$labelPreparing = htmlspecialchars($labelPreparing, \ENT_QUOTES, 'UTF-8');
// Zweizeilig dargestellt (per <br>, gerendert ueber innerHTML in fp-altcha.js): das
// <br> wird hier als normaler Attributwert htmlspecialchars-kodiert und vom Browser
// beim Auslesen per getAttribute() wieder zu einem echten Zeilenumbruch dekodiert.
$labelVerified = $isGerman ? 'Sicherheitsprüfung<br>bestanden' : 'Security check<br>passed';
$labelVerified = $isGerman ? 'Ja, ich bin ein Mensch' : 'Security check<br>passed';
$labelVerified = htmlspecialchars($labelVerified, \ENT_QUOTES, 'UTF-8');
$labelFailed = $isGerman
@@ -162,7 +159,7 @@ class TemplateHandler
: 'Please wait a moment, the security check is still running.';
$labelWait = htmlspecialchars($labelWait, \ENT_QUOTES, 'UTF-8');
$html = '<div class="fp-altcha form-group" data-fp-altcha="' . $challengeJson . '"'
$html = '<div class="fp-altcha form-group mt-2" data-fp-altcha="' . $challengeJson . '"'
. ' data-label-verified="' . $labelVerified . '"'
. ' data-label-failed="' . $labelFailed . '"'
. ' data-label-wait="' . $labelWait . '">'
@@ -171,9 +168,24 @@ class TemplateHandler
. '<input type="hidden" name="altcha" class="fp-altcha-input" value="">'
. '</div>';
// Direkt vor dem Absende-Button einfügen.
$submitButton = $form->find('button[type="submit"], input[type="submit"]');
// Bei schmalen Bootstrap-"input-group"-Formularen (z. B. das Newsletter-Feld im Footer:
// ein einzeiliges Eingabefeld mit direkt angehängtem Absende-Button) wuerde das Widget,
// direkt vor den Button gesetzt, mit in die schmale, einzeilige Button-Leiste gequetscht.
// In diesem Fall wird das Widget stattdessen als eigene Zeile UNTER der gesamten
// input-group platziert.
$inputGroup = null;
if (\count($submitButton) > 0) {
$candidateGroup = $submitButton->eq(0)->parents('.input-group')->eq(0);
if (\count($candidateGroup) > 0) {
$inputGroup = $candidateGroup;
}
}
if ($inputGroup !== null) {
$inputGroup->after($html);
} elseif (\count($submitButton) > 0) {
$submitButton->eq(0)->before($html);
} else {
$form->append($html);
@@ -27,6 +27,15 @@ 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.
*
* Jede Ablehnung wird zusätzlich (sofern in den Einstellungen aktiv) im Angriffsprotokoll
* festgehalten (siehe AltchaService::appendAttackLogEntry() und log.php im Plugin-Wurzelverzeichnis
* für die Ansicht) -- unabhängig vom nur für Tests gedachten Debug-Logging.
*/
class ValidationHandler
{
@@ -47,10 +56,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 +102,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 +144,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 +192,114 @@ 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->isAttackLogEnabled()) {
$this->altchaService->appendAttackLogEntry([
'zeit' => \date('c'),
'formular' => $formular,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
'sicherheitspruefung' => $blockedByAltcha ? 'fehlgeschlagen' : 'bestanden',
'namensmuster' => $blockedByPattern,
'honeypot' => $blockedByHoneypot,
]);
}
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'
));
}
}
@@ -63,6 +63,159 @@ class AltchaService
return $this->getConfigValue('fp_altcha_debug', '') === 'on';
}
/**
* Kommagetrennte Liste bekannter Bot-Namensmuster (z. B. "Test,Test User"), konfigurierbar
* in den Plugin-Einstellungen. Leere/nur aus Leerzeichen bestehende Einträge werden ignoriert.
*
* @return string[]
*/
public function getNameBlocklist(): array
{
$raw = $this->getConfigValue('fp_altcha_name_blocklist', '');
if ($raw === '') {
return [];
}
$parts = \array_map('trim', \explode(',', $raw));
return \array_values(\array_filter($parts, static fn(string $p): bool => $p !== ''));
}
/**
* Project Honey Pot (http:BL) ist nur aktiv, wenn ein Zugangsschluessel hinterlegt wurde.
*/
public function isHoneypotEnabled(): bool
{
return $this->getHoneypotApiKey() !== '';
}
public function getHoneypotApiKey(): string
{
return $this->getConfigValue('fp_altcha_honeypot_key', '');
}
/**
* Ab dieser von http:BL gemeldeten Bedrohungsstufe (0-255) wird zusaetzlich abgelehnt.
*/
public function getHoneypotThreshold(): int
{
return (int) $this->getConfigValue('fp_altcha_honeypot_threshold', '50');
}
/**
* Das Angriffsprotokoll ist unabhaengig vom (nur fuer Tests gedachten) Debug-Logging und
* standardmaessig aktiv: es haelt fortlaufend die zuletzt abgelehnten Anfragen fest.
*/
public function isAttackLogEnabled(): bool
{
return $this->getConfigValue('fp_altcha_attack_log', 'on') === 'on';
}
/**
* Haengt einen abgelehnten Versuch an die rotierende Protokolldatei an (siehe log.php fuer
* die Ansicht). Bewusst komplett fehlertolerant: schlaegt das Schreiben fehl (z. B. keine
* Schreibrechte), wird das still ignoriert -- die eigentliche Formularpruefung darf davon
* nie beeintraechtigt werden.
*
* @param array<string, mixed> $entry
*/
public function appendAttackLogEntry(array $entry): void
{
try {
$dir = $this->pluginDir . '/data';
$file = $dir . '/fp_altcha_attacklog.jsonl';
if (!\is_dir($dir)) {
@\mkdir($dir, 0755, true);
}
$line = \json_encode($entry, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
if (!\is_string($line)) {
return;
}
@\file_put_contents($file, $line . "\n", \FILE_APPEND | \LOCK_EX);
$this->trimAttackLog($file);
} catch (\Throwable $e) {
// Protokollieren ist ein Zusatzfeature -- ein Fehler hier darf niemals die eigentliche
// Formularpruefung stoeren.
}
}
/**
* Kuerzt die Protokolldatei auf die konfigurierte Hoechstanzahl an Eintraegen. Laeuft nur
* mit geringer Wahrscheinlichkeit bei jedem Aufruf (statt immer), um bei vielen gleichzeitigen
* Anfragen nicht staendig die komplette Datei einlesen zu muessen.
*/
private function trimAttackLog(string $file): void
{
if (\random_int(1, 20) !== 1) {
return;
}
if (!\is_file($file)) {
return;
}
$lines = @\file($file, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return;
}
$max = $this->getAttackLogMaxEntries();
if (\count($lines) <= $max) {
return;
}
$trimmed = \array_slice($lines, -$max);
@\file_put_contents($file, \implode("\n", $trimmed) . "\n", \LOCK_EX);
}
private function getAttackLogMaxEntries(): int
{
$value = (int) $this->getConfigValue('fp_altcha_attack_log_max', '300');
if ($value < 20 || $value > 5000) {
return 300;
}
return $value;
}
/**
* Liefert den Zugriffsschluessel fuer die eigenstaendige Protokoll-Ansicht (log.php). Wird
* beim allerersten Bedarf automatisch zufaellig erzeugt und in einer eigenen Datei im
* Plugin-Verzeichnis gespeichert -- exakt nach demselben Muster wie der HMAC-Geheimschluessel
* (siehe getOrCreateAutoSecret()).
*/
public function getLogViewToken(): string
{
$dir = $this->pluginDir . '/data';
$file = $dir . '/fp_altcha_log_token.php';
if (\is_file($file)) {
$value = include $file;
if (\is_string($value) && $value !== '') {
return $value;
}
}
$token = \bin2hex(\random_bytes(24));
if (!\is_dir($dir)) {
@\mkdir($dir, 0755, true);
}
@\file_put_contents(
$file,
"<?php\n\n// Automatisch erzeugter Zugriffsschluessel fuer die Protokoll-Ansicht (log.php) --"
. " nicht manuell bearbeiten.\n"
. 'return ' . \var_export($token, true) . ";\n"
);
return $token;
}
/**
* Erzeugt eine neue Prüfung und liefert sie als Array, das 1:1 als JSON in die Seite
* eingebettet werden kann (siehe TemplateHandler).