From ea850c39d15a5b93fbd3e73fd6d172a7f2623ba9 Mon Sep 17 00:00:00 2001 From: Tyrone Tudehope Date: Thu, 28 Nov 2024 08:08:28 +0200 Subject: [PATCH] feat: Add JS honeypot --- CHANGELOG.md | 2 +- src/Plugin.php | 36 ++++++++++++++++++++++++++------ src/config.php | 26 ++++++++++++++++++++--- src/models/Settings.php | 10 +++++++++ src/web/twig/Honeypot.php | 44 ++++++++++++++++++++++++++++++--------- 5 files changed, 98 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8be19..1bb173f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Release Notes for honeypot -## 1.0.0-beta +## 1.0.0@beta - Initial beta release diff --git a/src/Plugin.php b/src/Plugin.php index f0ad106..7703d7a 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -21,14 +21,24 @@ class Plugin extends BasePlugin { /** - * @var string[] + * @var int */ - private const LOG_LEVELS = ['debug', 'info', 'error', 'warning']; + public const DEFAULT_TIMETRAP_TIMEOUT = 2000; /** * @var int */ - private const DEFAULT_TIMETRAP_TIMEOUT = 2000; + public const DEFAULT_JS_TIMEOUT = 2000; + + /** + * @var string + */ + public const DEFAULT_JS_TEXT = 'verified'; + + /** + * @var string[] + */ + private const LOG_LEVELS = ['debug', 'info', 'error', 'warning']; public bool $hasCpSettings = true; @@ -82,6 +92,7 @@ function (Event $event): void { $honeypotValue = null; $timetrapValue = null; + $jsHoneypotValue = null; if ($settings->honeypotFieldName !== null) { $honeypotValue = $request->getBodyParam($settings->honeypotFieldName); @@ -92,7 +103,12 @@ function (Event $event): void { $timetrapValue = $request->getBodyParam($settings->timetrapFieldName); } - if ($honeypotValue === null && $timetrapValue === null) { + if ($settings->jsHoneypotFieldName !== null) { + /** @var ?string $jsHoneypotValue */ + $jsHoneypotValue = $request->getBodyParam($settings->jsHoneypotFieldName); + } + + if ($honeypotValue === null && $timetrapValue === null && $jsHoneypotValue === null) { // A bot simply has to remove the input fields altogether to bypass this check. return; } @@ -100,6 +116,13 @@ function (Event $event): void { $isSpamSubmission = false; $spamReasons = []; + // Honeypot test + if (! empty($honeypotValue)) { + $isSpamSubmission = true; + $spamReasons[] = 'Honeypot value was set'; + } + + // Timetrap test if ($timetrapValue !== null) { $timetrapValue = $this->decodeTimestamp($timetrapValue); if ($timetrapValue === false) { @@ -118,9 +141,10 @@ function (Event $event): void { } } - if (! empty($honeypotValue)) { + // Js honeypot test + if ($jsHoneypotValue !== null && $jsHoneypotValue !== self::DEFAULT_JS_TEXT) { $isSpamSubmission = true; - $spamReasons[] = 'Honeypot value was set'; + $spamReasons[] = 'JavaScript honeypot value was not set'; } if ($isSpamSubmission) { diff --git a/src/config.php b/src/config.php index 9e7dfa4..97885f9 100644 --- a/src/config.php +++ b/src/config.php @@ -11,11 +11,31 @@ * * This should be unique so that it does not conflict with any of your form inputs. */ - 'honeypotFieldName' => 'my_password', + 'honeypotFieldName' => 'set_my_password', - 'timeTrapFieldName' => 'honeypot_timetrap', + /** + * If set, the honeypot will include a timetrap field. + * + * This should be unique so that it does not conflict with any of your form inputs. + */ + 'timetrapFieldName' => 'honeypot_timetrap', - 'timeTrapTimeout' => 2000, + /** + * The timeout to be used with the timetrap. + */ + 'timetrapTimeout' => 2000, + + /** + * If set, a hidden input field will be included and extra JavaScript to set the value after a timeout ({@see jsHoneypotTimeout}) + * + * This should be unique so that it does not conflict with any of your form inputs. + */ + 'jsHoneypotFieldName' => 'verified_submission', + + /** + * The timeout to be used before the hidden input field is set on the client. + */ + 'jsHoneypotTimeout' => 2000, /** * Set to a string value to enable a response on non-dev environment. diff --git a/src/models/Settings.php b/src/models/Settings.php index b867a80..dfe8b74 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -30,6 +30,16 @@ class Settings extends Model */ public null|int|string $timetrapTimeout = 2000; + /** + * If set, a hidden input field will be included and extra JavaScript to set the value after a timeout ({@see jsHoneypotTimeout}) + */ + public ?string $jsHoneypotFieldName = 'verified_submission'; + + /** + * The timeout to be used before the hidden input field is set on the client. + */ + public null|int|string $jsHoneypotTimeout = 2000; + /** * Set to a string value to enable a response on non-dev environment. * diff --git a/src/web/twig/Honeypot.php b/src/web/twig/Honeypot.php index e6d8b34..94b8081 100644 --- a/src/web/twig/Honeypot.php +++ b/src/web/twig/Honeypot.php @@ -21,30 +21,54 @@ static function (): false|string { return false; } + $idPrefix = Craft::$app->getSecurity()->generateRandomString(12); + $inputs = []; + if ($settings->honeypotFieldName !== null) { + $inputs[] = Html::textInput( + $settings->honeypotFieldName, + '', + [ + 'id' => sprintf('%s_%s', $idPrefix, $settings->honeypotFieldName), + 'autocomplete' => 'off', + 'tabindex' => '-1', + 'style' => 'display:none; visibility:hidden; position:absolute; left:-9999px;', + ], + ); + } + if ($settings->timetrapFieldName !== null) { $timestamp = (new \DateTimeImmutable())->format('Uv'); $inputs[] = Html::hiddenInput( $settings->timetrapFieldName, - base64_encode(Craft::$app->getSecurity()->encryptByKey((string) $timestamp)), + base64_encode(Craft::$app->getSecurity()->encryptByKey($timestamp)), [ - 'id' => $settings->timetrapFieldName, - ] + 'id' => sprintf('%s_%s', $idPrefix, $settings->timetrapFieldName), + ], ); } - if ($settings->honeypotFieldName !== null) { - $inputs[] = Html::textInput( - $settings->honeypotFieldName, + if ($settings->jsHoneypotFieldName !== null) { + $jsInputId = sprintf('%s_%s', $idPrefix, $settings->jsHoneypotFieldName); + $inputs[] = Html::hiddenInput( + $settings->jsHoneypotFieldName, '', [ - 'id' => $settings->honeypotFieldName, - 'autocomplete' => 'off', - 'tabindex' => '-1', - 'style' => 'display:none; visibility:hidden; position:absolute; left:-9999px;', + 'id' => $jsInputId, ], ); + + $jsTimeout = $settings->jsHoneypotTimeout ?? Plugin::DEFAULT_JS_TIMEOUT; + $jsVerifiedText = Plugin::DEFAULT_JS_TEXT; // TODO add config for this + + $inputs[] = << + setTimeout(function () { + document.getElementById('{$jsInputId}').value = '{$jsVerifiedText}'; + }, {$jsTimeout}); + +EOJS; } return implode('', $inputs);