12 Commits
9 changed files with 482 additions and 40 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.
+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');
+48 -2
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.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).