Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
332e30bf57 | ||
|
|
96402e8005 | ||
|
|
6b2f05b96b | ||
|
|
5b11353bc1 | ||
|
|
b7dfe8f48b | ||
|
|
0c60f3d15e | ||
|
|
49308d311b | ||
|
|
3c1128ca4d | ||
|
|
17f5c7b98f | ||
|
|
2dbf11753a | ||
|
|
1e6fb0be5b | ||
|
|
bc27582a0d |
@@ -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,32 +1,5 @@
|
|||||||
# falk_plus ALTCHA Spam- und Botschutz
|
# 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).
|
weiteres siehe im [Guide](https://guide.falk.plus/doku.php?id=jtl-shop:altcha-spamschutz:start)
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
Fragen, Bugs oder Verbesserungsvorschläge 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.
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ class Bootstrap extends Bootstrapper
|
|||||||
$templateHandler = new TemplateHandler($plugin, $altchaService);
|
$templateHandler = new TemplateHandler($plugin, $altchaService);
|
||||||
$validationHandler = new ValidationHandler($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
|
// Früheste mögliche Prüfung der Newsletter-Anmeldung: läuft direkt hier, noch
|
||||||
// bevor JTL-Shop das $_POST verarbeitet. Siehe ValidationHandler::guardNewsletterSubmission().
|
// bevor JTL-Shop das $_POST verarbeitet. Siehe ValidationHandler::guardNewsletterSubmission().
|
||||||
$validationHandler->guardNewsletterSubmission();
|
$validationHandler->guardNewsletterSubmission();
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
status.innerHTML = '<i class="fa fa-check" aria-hidden="true"></i> ' +
|
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) {
|
} catch (e) {
|
||||||
container.setAttribute('data-fp-altcha-state', 'failed');
|
container.setAttribute('data-fp-altcha-state', 'failed');
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<XMLVersion>101</XMLVersion>
|
<XMLVersion>101</XMLVersion>
|
||||||
<MinShopVersion>5.2.0</MinShopVersion>
|
<MinShopVersion>5.2.0</MinShopVersion>
|
||||||
<CreateDate>2026-09-21</CreateDate>
|
<CreateDate>2026-09-21</CreateDate>
|
||||||
<Version>1.2.1</Version>
|
<Version>1.4.0</Version>
|
||||||
<Install>
|
<Install>
|
||||||
<FlushTags>CACHING_GROUP_TEMPLATE</FlushTags>
|
<FlushTags>CACHING_GROUP_TEMPLATE</FlushTags>
|
||||||
<!-- Hooks werden nicht über XML, sondern modern per EventDispatcher in Bootstrap.php registriert. -->
|
<!-- Hooks werden nicht über XML, sondern modern per EventDispatcher in Bootstrap.php registriert. -->
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<Name>fp_altcha_preparing</Name>
|
<Name>fp_altcha_preparing</Name>
|
||||||
</Variable>
|
</Variable>
|
||||||
<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>
|
<VariableLocalized iso="ENG">Security check passed</VariableLocalized>
|
||||||
<Description></Description>
|
<Description></Description>
|
||||||
<Name>fp_altcha_verified</Name>
|
<Name>fp_altcha_verified</Name>
|
||||||
@@ -68,6 +68,52 @@
|
|||||||
<ValueName>fp_altcha_expiry_seconds</ValueName>
|
<ValueName>fp_altcha_expiry_seconds</ValueName>
|
||||||
</Setting>
|
</Setting>
|
||||||
</Settingslink>
|
</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">
|
<Settingslink sort="10">
|
||||||
<Name>Formulare</Name>
|
<Name>Formulare</Name>
|
||||||
<Setting type="checkbox" initialValue="on" sort="0" conf="Y">
|
<Setting type="checkbox" initialValue="on" sort="0" conf="Y">
|
||||||
|
|||||||
@@ -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 – 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 – Angriffsprotokoll ('
|
||||||
|
. $anzahl . ' Einträ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ü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 = $isGerman ? 'Sicherheitsprüfung wird vorbereitet …' : 'Preparing security check …';
|
||||||
$labelPreparing = htmlspecialchars($labelPreparing, \ENT_QUOTES, 'UTF-8');
|
$labelPreparing = htmlspecialchars($labelPreparing, \ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
// Zweizeilig dargestellt (per <br>, gerendert ueber innerHTML in fp-altcha.js): das
|
$labelVerified = $isGerman ? 'Ja, ich bin ein Mensch' : 'Security check<br>passed';
|
||||||
// <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 = htmlspecialchars($labelVerified, \ENT_QUOTES, 'UTF-8');
|
$labelVerified = htmlspecialchars($labelVerified, \ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
$labelFailed = $isGerman
|
$labelFailed = $isGerman
|
||||||
@@ -162,7 +159,7 @@ class TemplateHandler
|
|||||||
: 'Please wait a moment, the security check is still running.';
|
: 'Please wait a moment, the security check is still running.';
|
||||||
$labelWait = htmlspecialchars($labelWait, \ENT_QUOTES, 'UTF-8');
|
$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-verified="' . $labelVerified . '"'
|
||||||
. ' data-label-failed="' . $labelFailed . '"'
|
. ' data-label-failed="' . $labelFailed . '"'
|
||||||
. ' data-label-wait="' . $labelWait . '">'
|
. ' data-label-wait="' . $labelWait . '">'
|
||||||
@@ -171,9 +168,24 @@ class TemplateHandler
|
|||||||
. '<input type="hidden" name="altcha" class="fp-altcha-input" value="">'
|
. '<input type="hidden" name="altcha" class="fp-altcha-input" value="">'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
|
|
||||||
// Direkt vor dem Absende-Button einfügen.
|
|
||||||
$submitButton = $form->find('button[type="submit"], input[type="submit"]');
|
$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) {
|
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);
|
$submitButton->eq(0)->before($html);
|
||||||
} else {
|
} else {
|
||||||
$form->append($html);
|
$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
|
* 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
|
* Newsletter-Anmeldung verwendet: direkte Prüfung von $_POST, noch bevor JTL-Shop das Formular
|
||||||
* verarbeitet (siehe guardContactSubmission()).
|
* 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
|
class ValidationHandler
|
||||||
{
|
{
|
||||||
@@ -47,10 +56,16 @@ class ValidationHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->altchaService->verifyPost()) {
|
$blockedByAltcha = !$this->altchaService->verifyPost();
|
||||||
|
$blockedByPattern = $this->matchesBlockedPattern();
|
||||||
|
$blockedByHoneypot = $this->isFlaggedByHoneypot();
|
||||||
|
|
||||||
|
if (!$blockedByAltcha && !$blockedByPattern && !$blockedByHoneypot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->logRejection('Registrierung', $blockedByAltcha, $blockedByPattern, $blockedByHoneypot);
|
||||||
|
|
||||||
if (isset($args['fehlendeAngaben']) && \is_array($args['fehlendeAngaben'])) {
|
if (isset($args['fehlendeAngaben']) && \is_array($args['fehlendeAngaben'])) {
|
||||||
$args['fehlendeAngaben'][] = 'Sicherheitsprüfung';
|
$args['fehlendeAngaben'][] = 'Sicherheitsprüfung';
|
||||||
}
|
}
|
||||||
@@ -87,10 +102,16 @@ class ValidationHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->altchaService->verifyPost()) {
|
$blockedByAltcha = !$this->altchaService->verifyPost();
|
||||||
|
$blockedByPattern = $this->matchesBlockedPattern();
|
||||||
|
$blockedByHoneypot = $this->isFlaggedByHoneypot();
|
||||||
|
|
||||||
|
if (!$blockedByAltcha && !$blockedByPattern && !$blockedByHoneypot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->logRejection('Newsletter-Anmeldung', $blockedByAltcha, $blockedByPattern, $blockedByHoneypot);
|
||||||
|
|
||||||
// Feld entfernen, damit JTL-Shop die Anmeldung nicht als "abonnieren"-Request erkennt
|
// Feld entfernen, damit JTL-Shop die Anmeldung nicht als "abonnieren"-Request erkennt
|
||||||
// und stattdessen nur das (leere) Formular erneut anzeigt.
|
// und stattdessen nur das (leere) Formular erneut anzeigt.
|
||||||
unset($_POST['abonnieren']);
|
unset($_POST['abonnieren']);
|
||||||
@@ -123,10 +144,16 @@ class ValidationHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->altchaService->verifyPost()) {
|
$blockedByAltcha = !$this->altchaService->verifyPost();
|
||||||
|
$blockedByPattern = $this->matchesBlockedPattern();
|
||||||
|
$blockedByHoneypot = $this->isFlaggedByHoneypot();
|
||||||
|
|
||||||
|
if (!$blockedByAltcha && !$blockedByPattern && !$blockedByHoneypot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->logRejection('Kontaktformular', $blockedByAltcha, $blockedByPattern, $blockedByHoneypot);
|
||||||
|
|
||||||
// Pflichtfeld leeren, damit JTL-Shops eigene Prüfung ("Pflichtfeld nicht ausgefüllt")
|
// Pflichtfeld leeren, damit JTL-Shops eigene Prüfung ("Pflichtfeld nicht ausgefüllt")
|
||||||
// die Anfrage abweist -- der Besucher bekommt die normale, vertraute Fehlermeldung.
|
// die Anfrage abweist -- der Besucher bekommt die normale, vertraute Fehlermeldung.
|
||||||
unset($_POST['nachricht']);
|
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';
|
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
|
* Erzeugt eine neue Prüfung und liefert sie als Array, das 1:1 als JSON in die Seite
|
||||||
* eingebettet werden kann (siehe TemplateHandler).
|
* eingebettet werden kann (siehe TemplateHandler).
|
||||||
|
|||||||
Reference in New Issue
Block a user