8 Commits
5 changed files with 214 additions and 30 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.
+29 -2
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.3.0</Version>
<Install>
<FlushTags>CACHING_GROUP_TEMPLATE</FlushTags>
<!-- Hooks werden nicht über XML, sondern modern per EventDispatcher in Bootstrap.php registriert. -->
@@ -68,6 +68,33 @@
<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="10">
<Name>Formulare</Name>
<Setting type="checkbox" initialValue="on" sort="0" conf="Y">
@@ -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'
));
}
}
@@ -63,6 +63,45 @@ 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');
}
/**
* Erzeugt eine neue Prüfung und liefert sie als Array, das 1:1 als JSON in die Seite
* eingebettet werden kann (siehe TemplateHandler).