Files
HoneypotFields/honeypot-fields.php
Malin c2a29ffbbe feat: honeypot fields v2.0 with JS challenge, rate limiting, and proper WooCommerce hooks
- 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
2026-02-16 09:54:17 +01:00

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;
});