7 Commits
7 changed files with 273 additions and 10 deletions
+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');
+22 -3
View File
@@ -8,7 +8,7 @@
<XMLVersion>101</XMLVersion>
<MinShopVersion>5.2.0</MinShopVersion>
<CreateDate>2026-09-21</CreateDate>
<Version>1.3.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>
@@ -74,7 +74,7 @@
<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>
</Setting>
</Settingslink>
<Settingslink sort="8">
<Name>Project Honey Pot</Name>
@@ -95,6 +95,25 @@
</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);
@@ -32,6 +32,10 @@ use Plugin\fp_altcha_spamschutz\src\Service\AltchaService;
* 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
{
@@ -275,6 +279,17 @@ class ValidationHandler
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;
}
@@ -102,6 +102,120 @@ class AltchaService
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).