- Require honeypot field presence (blocks direct POST bots) - Add HMAC-based JavaScript proof-of-work token via SubtleCrypto - Hook into woocommerce_process_registration_errors for proper validation - Add IP-based rate limiting (3 registrations/hour) via transients - Add timestamp validation (min 3s, max 2h) - Use realistic field names to avoid bot detection - Support WP core registration, comments, Elementor, Gravity Forms, CF7
543 lines
19 KiB
PHP
543 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* Plugin Name: Honeypot Fields
|
|
* Plugin URI: https://informatiq.services
|
|
* Description: Adds invisible honeypot fields to all forms to block spam bots. Works with WordPress core forms, Elementor, Gravity Forms, Contact Form 7, WooCommerce, and more.
|
|
* Version: 2.0.0
|
|
* Author: Malin
|
|
* Author URI: https://malin.ro
|
|
* License: GPL v2 or later
|
|
* Text Domain: smart-honeypot
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class SmartHoneypotAntiSpam {
|
|
|
|
/** Honeypot text input name — looks like a real field bots want to fill */
|
|
private $hp_name;
|
|
|
|
/** JS challenge token field name */
|
|
private $token_name;
|
|
|
|
/** Timestamp field name */
|
|
private $time_name;
|
|
|
|
/** Minimum seconds before a form can be submitted */
|
|
private const MIN_SUBMIT_TIME = 3;
|
|
|
|
/** Maximum age (seconds) a form timestamp is valid */
|
|
private const MAX_SUBMIT_TIME = 7200; // 2 hours
|
|
|
|
/** Rate limit: max registrations per IP per hour */
|
|
private const RATE_LIMIT = 3;
|
|
|
|
/** HMAC secret derived once per site */
|
|
private $secret;
|
|
|
|
public function __construct() {
|
|
// Derive a stable per-site secret
|
|
$this->secret = wp_hash('honeypot_plugin_secret_v2');
|
|
|
|
// Field names look like legitimate form fields to trick bots
|
|
$this->hp_name = 'website_url_confirm';
|
|
$this->token_name = 'form_session_id';
|
|
$this->time_name = 'form_render_ts';
|
|
|
|
add_action('init', [$this, 'init']);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* INIT — register all hooks
|
|
* ----------------------------------------------------------------*/
|
|
public function init() {
|
|
if (is_admin()) {
|
|
add_action('admin_notices', [$this, 'activation_notice']);
|
|
return;
|
|
}
|
|
|
|
// --- Inject honeypot into forms ---
|
|
|
|
// WordPress core
|
|
add_filter('the_content', [$this, 'add_to_content_forms'], 99);
|
|
add_filter('comment_form_defaults', [$this, 'add_to_comment_form_defaults']);
|
|
add_action('comment_form_after_fields', [$this, 'echo_honeypot']);
|
|
add_action('comment_form_logged_in_after', [$this, 'echo_honeypot']);
|
|
|
|
// WooCommerce
|
|
add_action('woocommerce_register_form', [$this, 'echo_honeypot']);
|
|
add_action('woocommerce_login_form', [$this, 'echo_honeypot']);
|
|
add_action('woocommerce_after_order_notes', [$this, 'echo_honeypot']);
|
|
|
|
// WordPress registration
|
|
add_action('register_form', [$this, 'echo_honeypot']);
|
|
add_action('login_form', [$this, 'echo_honeypot']);
|
|
|
|
// Elementor Pro forms
|
|
add_action('elementor_pro/forms/render_field', [$this, 'add_to_elementor_form'], 10, 2);
|
|
add_action('elementor/widget/render_content', [$this, 'filter_elementor_widget'], 10, 2);
|
|
|
|
// Gravity Forms
|
|
add_filter('gform_form_tag', [$this, 'add_to_gravity_forms'], 10, 2);
|
|
|
|
// Contact Form 7
|
|
add_filter('wpcf7_form_elements', [$this, 'add_to_cf7']);
|
|
|
|
// Generic form search
|
|
add_filter('get_search_form', [$this, 'add_to_search_form'], 99);
|
|
|
|
// --- Validate on POST ---
|
|
|
|
// WooCommerce registration (proper hook)
|
|
add_filter('woocommerce_process_registration_errors', [$this, 'validate_wc_registration'], 10, 4);
|
|
|
|
// WooCommerce login
|
|
add_filter('woocommerce_process_login_errors', [$this, 'validate_wc_login'], 10, 3);
|
|
|
|
// WooCommerce checkout
|
|
add_action('woocommerce_after_checkout_validation', [$this, 'validate_wc_checkout'], 10, 2);
|
|
|
|
// WordPress core registration
|
|
add_filter('registration_errors', [$this, 'validate_wp_registration'], 10, 3);
|
|
|
|
// Comments
|
|
add_filter('preprocess_comment', [$this, 'validate_comment']);
|
|
|
|
// Elementor Pro forms
|
|
add_action('elementor_pro/forms/validation', [$this, 'validate_elementor_form'], 10, 2);
|
|
|
|
// Generic early POST check for other forms
|
|
add_action('template_redirect', [$this, 'validate_generic_post']);
|
|
|
|
// --- CSS & JS ---
|
|
add_action('wp_head', [$this, 'print_css']);
|
|
add_action('wp_footer', [$this, 'print_js'], 99);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* HONEYPOT FIELD HTML
|
|
* ----------------------------------------------------------------*/
|
|
private function get_honeypot_html() {
|
|
$ts = time();
|
|
$token_data = $ts . '|' . wp_create_nonce('hp_form_' . $ts);
|
|
|
|
// The wrapper uses a generic class name
|
|
return sprintf(
|
|
'<div class="frm-extra-field" aria-hidden="true">
|
|
<label for="%1$s">Website URL Confirmation</label>
|
|
<input type="text" id="%1$s" name="%1$s" value="" tabindex="-1" autocomplete="off" />
|
|
<input type="hidden" name="%2$s" value="" />
|
|
<input type="hidden" name="%3$s" value="%4$s" />
|
|
</div>',
|
|
esc_attr($this->hp_name),
|
|
esc_attr($this->token_name),
|
|
esc_attr($this->time_name),
|
|
esc_attr($ts)
|
|
);
|
|
}
|
|
|
|
/** Echo the honeypot (for action hooks) */
|
|
public function echo_honeypot() {
|
|
echo $this->get_honeypot_html();
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* INJECTION HELPERS
|
|
* ----------------------------------------------------------------*/
|
|
public function add_to_content_forms($content) {
|
|
if (is_admin() || is_feed()) {
|
|
return $content;
|
|
}
|
|
return preg_replace_callback(
|
|
'/(<form\b[^>]*>)(.*?)(<\/form>)/is',
|
|
function ($m) {
|
|
return $m[1] . $m[2] . $this->get_honeypot_html() . $m[3];
|
|
},
|
|
$content
|
|
);
|
|
}
|
|
|
|
public function add_to_comment_form_defaults($defaults) {
|
|
if (isset($defaults['fields']['url'])) {
|
|
// Honeypot already has a URL-looking field; inject after existing fields
|
|
}
|
|
return $defaults;
|
|
}
|
|
|
|
public function add_to_search_form($form) {
|
|
return preg_replace(
|
|
'/(<\/form>)/i',
|
|
$this->get_honeypot_html() . '$1',
|
|
$form,
|
|
1
|
|
);
|
|
}
|
|
|
|
public function add_to_elementor_form($field, $instance) {
|
|
static $done = false;
|
|
if (!$done && $field['type'] === 'submit') {
|
|
$done = true;
|
|
echo $this->get_honeypot_html();
|
|
}
|
|
}
|
|
|
|
public function filter_elementor_widget($content, $widget) {
|
|
if ($widget->get_name() === 'form') {
|
|
$content = preg_replace(
|
|
'/(<\/form>)/i',
|
|
$this->get_honeypot_html() . '$1',
|
|
$content,
|
|
1
|
|
);
|
|
}
|
|
return $content;
|
|
}
|
|
|
|
public function add_to_gravity_forms($tag, $form) {
|
|
return preg_replace(
|
|
'/(<div class="gform_footer)/i',
|
|
$this->get_honeypot_html() . '$1',
|
|
$tag,
|
|
1
|
|
);
|
|
}
|
|
|
|
public function add_to_cf7($form) {
|
|
return preg_replace(
|
|
'/(\[submit[^\]]*\])/i',
|
|
$this->get_honeypot_html() . '$1',
|
|
$form,
|
|
1
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* CORE VALIDATION LOGIC
|
|
*
|
|
* Returns true if the submission looks legitimate, false if spam.
|
|
* $require_fields = true means the honeypot fields MUST be present
|
|
* (i.e. the form definitely had them injected).
|
|
* ----------------------------------------------------------------*/
|
|
private function check_submission(bool $require_fields = true): bool {
|
|
// 1. Honeypot text field: must be PRESENT and EMPTY
|
|
if ($require_fields) {
|
|
if (!isset($_POST[$this->hp_name])) {
|
|
$this->log_spam('Honeypot field missing (direct POST without form)');
|
|
return false;
|
|
}
|
|
}
|
|
if (isset($_POST[$this->hp_name]) && $_POST[$this->hp_name] !== '') {
|
|
$this->log_spam('Honeypot field filled in');
|
|
return false;
|
|
}
|
|
|
|
// 2. JS token must be present and non-empty (proves JS executed)
|
|
if ($require_fields) {
|
|
if (empty($_POST[$this->token_name])) {
|
|
$this->log_spam('JS token missing (no JavaScript execution)');
|
|
return false;
|
|
}
|
|
// Validate token format: "timestamp|hash"
|
|
if (!$this->validate_js_token($_POST[$this->token_name])) {
|
|
$this->log_spam('JS token invalid');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 3. Timestamp check
|
|
if ($require_fields && !isset($_POST[$this->time_name])) {
|
|
$this->log_spam('Timestamp field missing');
|
|
return false;
|
|
}
|
|
if (isset($_POST[$this->time_name])) {
|
|
$ts = intval($_POST[$this->time_name]);
|
|
$now = time();
|
|
$diff = $now - $ts;
|
|
if ($diff < self::MIN_SUBMIT_TIME) {
|
|
$this->log_spam("Submitted too fast ({$diff}s)");
|
|
return false;
|
|
}
|
|
if ($diff > self::MAX_SUBMIT_TIME) {
|
|
$this->log_spam("Timestamp too old ({$diff}s)");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Clean up honeypot fields so downstream code never sees them
|
|
$this->clean_post_data();
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Validate the JS-generated token */
|
|
private function validate_js_token(string $token): bool {
|
|
// Token format: "nonce_value" set by JS using the hidden field
|
|
// JS computes: HMAC of (timestamp + site-specific value)
|
|
// We verify it matches what we'd expect
|
|
$parts = explode('|', $token);
|
|
if (count($parts) !== 2) {
|
|
return false;
|
|
}
|
|
$ts = intval($parts[0]);
|
|
$hash = $parts[1];
|
|
$expected = hash_hmac('sha256', $ts . '|honeypot_js_proof', $this->secret);
|
|
return hash_equals(substr($expected, 0, 16), $hash);
|
|
}
|
|
|
|
/** Remove honeypot fields from $_POST / $_REQUEST */
|
|
private function clean_post_data() {
|
|
foreach ([$this->hp_name, $this->token_name, $this->time_name] as $key) {
|
|
unset($_POST[$key], $_REQUEST[$key]);
|
|
}
|
|
}
|
|
|
|
private function log_spam(string $reason) {
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
|
$uri = $_SERVER['REQUEST_URI'] ?? 'unknown';
|
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
|
|
error_log("[Honeypot] SPAM blocked: {$reason} | IP: {$ip} | URI: {$uri} | UA: {$ua}");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* RATE LIMITING
|
|
* ----------------------------------------------------------------*/
|
|
private function check_rate_limit(): bool {
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
if (!$ip) {
|
|
return false;
|
|
}
|
|
$key = 'hp_rate_' . md5($ip);
|
|
$count = (int) get_transient($key);
|
|
if ($count >= self::RATE_LIMIT) {
|
|
$this->log_spam("Rate limit exceeded ({$count} attempts) for IP {$ip}");
|
|
return false;
|
|
}
|
|
set_transient($key, $count + 1, HOUR_IN_SECONDS);
|
|
return true;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* WOOCOMMERCE VALIDATION
|
|
* ----------------------------------------------------------------*/
|
|
public function validate_wc_registration($errors, $username, $password, $email) {
|
|
if (!$this->check_submission(true)) {
|
|
$errors->add(
|
|
'honeypot_spam',
|
|
__('<strong>Error</strong>: Registration blocked. If you are not a bot, please enable JavaScript and try again.', 'smart-honeypot')
|
|
);
|
|
return $errors;
|
|
}
|
|
if (!$this->check_rate_limit()) {
|
|
$errors->add(
|
|
'honeypot_rate',
|
|
__('<strong>Error</strong>: Too many registration attempts. Please try again later.', 'smart-honeypot')
|
|
);
|
|
}
|
|
return $errors;
|
|
}
|
|
|
|
public function validate_wc_login($errors, $username, $password) {
|
|
if (!$this->check_submission(true)) {
|
|
$errors->add(
|
|
'honeypot_spam',
|
|
__('<strong>Error</strong>: Login blocked. Please enable JavaScript and try again.', 'smart-honeypot')
|
|
);
|
|
}
|
|
return $errors;
|
|
}
|
|
|
|
public function validate_wc_checkout($data, $errors) {
|
|
if (!$this->check_submission(false)) {
|
|
$errors->add(
|
|
'honeypot_spam',
|
|
__('Order could not be processed. Please try again.', 'smart-honeypot')
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* WORDPRESS CORE VALIDATION
|
|
* ----------------------------------------------------------------*/
|
|
public function validate_wp_registration($errors, $sanitized_user_login, $user_email) {
|
|
if (!$this->check_submission(true)) {
|
|
$errors->add(
|
|
'honeypot_spam',
|
|
__('<strong>Error</strong>: Registration blocked. Please enable JavaScript and try again.', 'smart-honeypot')
|
|
);
|
|
}
|
|
if (!$this->check_rate_limit()) {
|
|
$errors->add(
|
|
'honeypot_rate',
|
|
__('<strong>Error</strong>: Too many attempts. Please try again later.', 'smart-honeypot')
|
|
);
|
|
}
|
|
return $errors;
|
|
}
|
|
|
|
public function validate_comment($commentdata) {
|
|
// Don't check admins/editors
|
|
if (is_user_logged_in() && current_user_can('moderate_comments')) {
|
|
$this->clean_post_data();
|
|
return $commentdata;
|
|
}
|
|
if (!$this->check_submission(true)) {
|
|
wp_die(
|
|
__('Comment blocked as spam. Please enable JavaScript and try again.', 'smart-honeypot'),
|
|
__('Spam Detected', 'smart-honeypot'),
|
|
['response' => 403, 'back_link' => true]
|
|
);
|
|
}
|
|
return $commentdata;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* ELEMENTOR VALIDATION
|
|
* ----------------------------------------------------------------*/
|
|
public function validate_elementor_form($record, $ajax_handler) {
|
|
if (!$this->check_submission(true)) {
|
|
$ajax_handler->add_error('honeypot', __('Spam detected. Please try again.', 'smart-honeypot'));
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* GENERIC POST VALIDATION (catch-all for other forms)
|
|
* ----------------------------------------------------------------*/
|
|
public function validate_generic_post() {
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
return;
|
|
}
|
|
|
|
// Only validate if our honeypot fields are present (don't interfere
|
|
// with forms we didn't inject into, admin-ajax, REST API, etc.)
|
|
if (!isset($_POST[$this->hp_name]) && !isset($_POST[$this->token_name])) {
|
|
return;
|
|
}
|
|
|
|
// Skip if already handled by a specific hook above
|
|
if (
|
|
isset($_POST['woocommerce-register-nonce']) ||
|
|
isset($_POST['woocommerce-login-nonce']) ||
|
|
isset($_POST['woocommerce-process-checkout-nonce']) ||
|
|
isset($_POST['comment_post_ID']) ||
|
|
(isset($_POST['action']) && $_POST['action'] === 'elementor_pro_forms_send_form')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!$this->check_submission(false)) {
|
|
if (wp_doing_ajax()) {
|
|
wp_send_json_error(['message' => __('Spam detected.', 'smart-honeypot')]);
|
|
}
|
|
$ref = wp_get_referer();
|
|
wp_safe_redirect($ref ?: home_url());
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* CSS
|
|
* ----------------------------------------------------------------*/
|
|
public function print_css() {
|
|
// Use a nondescript class name
|
|
echo '<style>
|
|
.frm-extra-field{position:absolute!important;left:-9999px!important;top:-9999px!important;height:0!important;width:0!important;overflow:hidden!important;opacity:0!important;pointer-events:none!important;z-index:-1!important;clip:rect(0,0,0,0)!important}
|
|
</style>';
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* JS — generates the proof-of-work token
|
|
* ----------------------------------------------------------------*/
|
|
public function print_js() {
|
|
$secret = esc_js($this->secret);
|
|
$token_name = esc_js($this->token_name);
|
|
$time_name = esc_js($this->time_name);
|
|
|
|
echo <<<JSBLOCK
|
|
<script>
|
|
(function(){
|
|
function computeToken(ts, secret) {
|
|
// Simple HMAC-like hash using SubtleCrypto
|
|
var msg = ts + '|honeypot_js_proof';
|
|
var encoder = new TextEncoder();
|
|
return crypto.subtle.importKey(
|
|
'raw', encoder.encode(secret), {name:'HMAC',hash:'SHA-256'}, false, ['sign']
|
|
).then(function(key){
|
|
return crypto.subtle.sign('HMAC', key, encoder.encode(msg));
|
|
}).then(function(sig){
|
|
var arr = Array.from(new Uint8Array(sig));
|
|
var hex = arr.map(function(b){return b.toString(16).padStart(2,'0')}).join('');
|
|
return hex.substring(0, 16);
|
|
});
|
|
}
|
|
|
|
function fillTokens() {
|
|
var forms = document.querySelectorAll('input[name="{$token_name}"]');
|
|
forms.forEach(function(input){
|
|
var tsInput = input.parentElement.querySelector('input[name="{$time_name}"]');
|
|
var ts = tsInput ? tsInput.value : Math.floor(Date.now()/1000).toString();
|
|
computeToken(ts, '{$secret}').then(function(hash){
|
|
input.value = ts + '|' + hash;
|
|
});
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', fillTokens);
|
|
} else {
|
|
fillTokens();
|
|
}
|
|
|
|
// Re-fill for dynamically added forms
|
|
var obs = new MutationObserver(function(mutations){
|
|
var found = false;
|
|
mutations.forEach(function(m){
|
|
m.addedNodes.forEach(function(n){
|
|
if (n.nodeType === 1 && (n.querySelector && n.querySelector('input[name="{$token_name}"]'))) {
|
|
found = true;
|
|
}
|
|
});
|
|
});
|
|
if (found) fillTokens();
|
|
});
|
|
obs.observe(document.body, {childList:true, subtree:true});
|
|
})();
|
|
</script>
|
|
JSBLOCK;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* ADMIN NOTICE
|
|
* ----------------------------------------------------------------*/
|
|
public function activation_notice() {
|
|
if (get_transient('smart_honeypot_activated')) {
|
|
echo '<div class="notice notice-success is-dismissible">
|
|
<p><strong>Honeypot Fields</strong> is now active. All forms are protected automatically.</p>
|
|
</div>';
|
|
delete_transient('smart_honeypot_activated');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Boot
|
|
add_action('plugins_loaded', function () {
|
|
new SmartHoneypotAntiSpam();
|
|
});
|
|
|
|
// Activation
|
|
register_activation_hook(__FILE__, function () {
|
|
set_transient('smart_honeypot_activated', true, 30);
|
|
});
|
|
|
|
// Deactivation
|
|
register_deactivation_hook(__FILE__, function () {
|
|
delete_transient('smart_honeypot_activated');
|
|
});
|
|
|
|
// Settings link
|
|
add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) {
|
|
array_unshift($links, '<a href="https://informatiq.services" target="_blank">Documentation</a>');
|
|
return $links;
|
|
});
|