5 Commits
5 changed files with 211 additions and 32 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.
+1 -28
View File
@@ -1,32 +1,5 @@
# falk_plus ALTCHA Spam- und Botschutz
Selbst gehostetes, quelloffenes Spam- und Botschutz-Plugin (ALTCHA Proof-of-Work, MIT-Lizenz) für JTL-Shop 5 -- schützt Registrierung, Newsletter-Anmeldung und Kontaktformular, ohne Daten an Dritte weiterzugeben (kein Google reCAPTCHA, keine Umleitung über Cloudflare oder ähnliche Dienste).
## Enthält
- `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` -- Prüfung bei Registrierung, Newsletter-Anmeldung und Kontaktformular
- `assets/fp-altcha.js` -- client-seitiger Proof-of-Work-Löser
Die installierbare Plugin-ZIP enthält zusätzlich die unverändert 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: über Wochen liefen massenhaft Fake-Registrierungen und Newsletter-Anmeldungen mit immer demselben Muster auf ("Test"/"Test User" in Vorname/Nachname/Ort/Straße, aber echte, unterschiedliche Fremd-E-Mail-Adressen). Rate-Limiting und andere getestete Maßnahmen konnten das Problem nicht zuverlässig lösen, da deren Prüfungen an anderer Stelle ansetzen (z. B. an Kommentar-/Nachrichtenfeldern, die weder das Registrierungs- noch das Newsletter-Formular besitzen). Dieses Plugin prüft stattdessen direkt beim Absenden von Registrierung, Newsletter-Anmeldung und Kontaktformular selbst -- unabhängig davon, welche Felder das Formular sonst enthält.
## Installation / Download
Die direkt installierbare Plugin-ZIP (inkl. der mitgelieferten ALTCHA-PHP-Bibliothek) gibt es aktuell auf diesen Wegen:
1. **Fertige ZIP unter [Releases](https://vw.falk.plus/JensFalk/JTL-Shop-ALTCHA-Spamschutz/releases)**: zu jeder Version gibt es dort einen Eintrag; sobald eine ZIP-Datei als Anhang vorliegt, lässt sie sich ohne weitere Anpassung direkt im JTL-Shop-Backend unter *Plugins → Plugin hochladen* installieren.
2. **Quellcode-Download über "Code → Download ZIP"**: dieser Weg liefert den reinen Quellcode dieses Repos (ohne die ALTCHA-PHP-Bibliothek) und packt ihn zusätzlich in einen Ordner mit Repo- und Branch-Namen. Für die Installation muss danach der enthaltene Ordner `fp_altcha_spamschutz` neu, direkt als Wurzelverzeichnis, gezippt werden.
Ausführliche Installations-, Test- und Hintergrundinfos im Guide: [jtl-shop:altcha-spamschutz:start](https://guide.falk.plus/doku.php?id=jtl-shop:altcha-spamschutz:start)
## Feedback
weiteres siehe im [Guide](https://guide.falk.plus/doku.php?id=jtl-shop:altcha-spamschutz:start)
Fragen, Bugs oder Verbesserungsvorschläge bitte als [Issue in diesem Repo](https://vw.falk.plus/JensFalk/JTL-Shop-ALTCHA-Spamschutz/issues) melden.
+28 -1
View File
@@ -8,7 +8,7 @@
<XMLVersion>101</XMLVersion>
<MinShopVersion>5.2.0</MinShopVersion>
<CreateDate>2026-09-21</CreateDate>
<Version>1.2.1</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).