feat: add DB logging and admin UI for blocked attempts

- SmartHoneypotDB class: creates wp_honeypot_log table (id, blocked_at,
  ip_address, form_type, reason, request_uri, user_agent)
- SmartHoneypotAdmin class: admin menu page 'Honeypot Logs' with:
  * Stats cards (total blocked, blocked today, unique IPs, form types hit)
  * Filterable table (by IP, form type, free-text search)
  * Pagination (25 per page)
  * IP lookup link (ipinfo.io) and quick IP filter per row
  * Clear All Logs button
- log_spam() now writes to DB with form_type context
- current_form_type property threads form name into every log entry
- View Logs link added to plugin action links
- Auto-prune logs older than 90 days via WP cron
- DB schema versioning for future migrations
This commit is contained in:
2026-03-09 17:11:09 +01:00
parent c2a29ffbbe
commit a3e38faffa

View File

@@ -3,7 +3,7 @@
* 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
* Version: 2.1.0
* Author: Malin
* Author URI: https://malin.ro
* License: GPL v2 or later
@@ -14,6 +14,406 @@ if (!defined('ABSPATH')) {
exit;
}
/* ======================================================================
* DATABASE HELPER
* ====================================================================*/
class SmartHoneypotDB {
const TABLE_VERSION = 1;
const TABLE_VERSION_OPTION = 'hp_db_version';
public static function table(): string {
global $wpdb;
return $wpdb->prefix . 'honeypot_log';
}
public static function install() {
global $wpdb;
$table = self::table();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
blocked_at DATETIME NOT NULL,
ip_address VARCHAR(45) NOT NULL DEFAULT '',
form_type VARCHAR(100) NOT NULL DEFAULT '',
reason VARCHAR(255) NOT NULL DEFAULT '',
request_uri VARCHAR(1000) NOT NULL DEFAULT '',
user_agent TEXT NOT NULL,
PRIMARY KEY (id),
KEY ip_address (ip_address),
KEY blocked_at (blocked_at),
KEY form_type (form_type)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
update_option(self::TABLE_VERSION_OPTION, self::TABLE_VERSION);
}
public static function insert(array $data) {
global $wpdb;
$wpdb->insert(
self::table(),
[
'blocked_at' => current_time('mysql'),
'ip_address' => sanitize_text_field($data['ip'] ?? ''),
'form_type' => sanitize_text_field($data['form'] ?? 'Unknown'),
'reason' => sanitize_text_field($data['reason'] ?? ''),
'request_uri' => esc_url_raw(substr($data['uri'] ?? '', 0, 1000)),
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
],
['%s', '%s', '%s', '%s', '%s', '%s']
);
}
public static function get_rows(array $args = []): array {
global $wpdb;
$table = self::table();
$limit = max(1, intval($args['per_page'] ?? 25));
$offset = max(0, intval($args['offset'] ?? 0));
$where = '1=1';
$params = [];
if (!empty($args['ip'])) {
$where .= ' AND ip_address = %s';
$params[] = sanitize_text_field($args['ip']);
}
if (!empty($args['form'])) {
$where .= ' AND form_type = %s';
$params[] = sanitize_text_field($args['form']);
}
if (!empty($args['search'])) {
$like = '%' . $wpdb->esc_like($args['search']) . '%';
$where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$params[] = $limit;
$params[] = $offset;
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY blocked_at DESC LIMIT %d OFFSET %d";
if ($params) {
$sql = $wpdb->prepare($sql, $params);
}
return $wpdb->get_results($sql) ?: [];
}
public static function count(array $args = []): int {
global $wpdb;
$table = self::table();
$where = '1=1';
$params = [];
if (!empty($args['ip'])) {
$where .= ' AND ip_address = %s';
$params[] = sanitize_text_field($args['ip']);
}
if (!empty($args['form'])) {
$where .= ' AND form_type = %s';
$params[] = sanitize_text_field($args['form']);
}
if (!empty($args['search'])) {
$like = '%' . $wpdb->esc_like($args['search']) . '%';
$where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
if ($params) {
$sql = $wpdb->prepare($sql, $params);
}
return (int) $wpdb->get_var($sql);
}
public static function get_form_types(): array {
global $wpdb;
return $wpdb->get_col("SELECT DISTINCT form_type FROM " . self::table() . " ORDER BY form_type ASC") ?: [];
}
public static function clear(): int {
global $wpdb;
return (int) $wpdb->query("TRUNCATE TABLE " . self::table());
}
public static function delete_older_than_days(int $days) {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
"DELETE FROM " . self::table() . " WHERE blocked_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
$days
)
);
}
}
/* ======================================================================
* ADMIN PAGE
* ====================================================================*/
class SmartHoneypotAdmin {
const MENU_SLUG = 'honeypot-logs';
const NONCE_ACTION = 'hp_admin_action';
const PER_PAGE = 25;
public static function register() {
add_action('admin_menu', [self::class, 'add_menu']);
add_action('admin_init', [self::class, 'handle_actions']);
add_action('admin_enqueue_scripts', [self::class, 'enqueue_styles']);
add_filter('plugin_action_links_' . plugin_basename(HP_PLUGIN_FILE), [self::class, 'plugin_links']);
}
public static function plugin_links($links) {
$log_link = '<a href="' . admin_url('admin.php?page=' . self::MENU_SLUG) . '">View Logs</a>';
array_unshift($links, $log_link);
array_push($links, '<a href="https://informatiq.services" target="_blank">Documentation</a>');
return $links;
}
public static function add_menu() {
add_menu_page(
'Honeypot Logs',
'Honeypot Logs',
'manage_options',
self::MENU_SLUG,
[self::class, 'render_page'],
'dashicons-shield-alt',
81
);
}
public static function enqueue_styles($hook) {
if ($hook !== 'toplevel_page_' . self::MENU_SLUG) {
return;
}
// Inline styles — no external file needed
$css = '
#hp-log-wrap { max-width: 1400px; }
#hp-log-wrap .hp-stats { display:flex; gap:16px; margin:16px 0; flex-wrap:wrap; }
#hp-log-wrap .hp-stat-card {
background:#fff; border:1px solid #c3c4c7; border-radius:4px;
padding:16px 24px; min-width:140px; text-align:center;
}
#hp-log-wrap .hp-stat-card .hp-stat-num { font-size:2em; font-weight:700; color:#2271b1; line-height:1.2; }
#hp-log-wrap .hp-stat-card .hp-stat-lbl { color:#646970; font-size:12px; }
#hp-log-wrap .hp-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
#hp-log-wrap .hp-filters input, #hp-log-wrap .hp-filters select { height:32px; }
#hp-log-wrap table.hp-log-table { width:100%; border-collapse:collapse; background:#fff; }
#hp-log-wrap table.hp-log-table th {
background:#f0f0f1; padding:8px 12px; text-align:left;
border-bottom:2px solid #c3c4c7; white-space:nowrap;
}
#hp-log-wrap table.hp-log-table td { padding:8px 12px; border-bottom:1px solid #f0f0f1; vertical-align:top; }
#hp-log-wrap table.hp-log-table tr:hover td { background:#f6f7f7; }
#hp-log-wrap .hp-ua { font-size:11px; color:#646970; max-width:300px; word-break:break-all; }
#hp-log-wrap .hp-badge {
display:inline-block; padding:2px 8px; border-radius:3px; font-size:11px; font-weight:600;
background:#ffecec; color:#b32d2e; border:1px solid #f7c5c5;
}
#hp-log-wrap .hp-pagination { margin:12px 0; display:flex; align-items:center; gap:8px; }
#hp-log-wrap .hp-pagination a, #hp-log-wrap .hp-pagination span {
display:inline-block; padding:4px 10px; border:1px solid #c3c4c7;
border-radius:3px; background:#fff; text-decoration:none; color:#2271b1;
}
#hp-log-wrap .hp-pagination span.current { background:#2271b1; color:#fff; border-color:#2271b1; }
#hp-log-wrap .hp-clear-btn { color:#b32d2e; }
';
wp_add_inline_style('common', $css);
}
public static function handle_actions() {
if (!isset($_POST['hp_action']) || !check_admin_referer(self::NONCE_ACTION)) {
return;
}
if (!current_user_can('manage_options')) {
wp_die('Unauthorized');
}
if ($_POST['hp_action'] === 'clear_logs') {
SmartHoneypotDB::clear();
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'cleared' => 1], admin_url('admin.php')));
exit;
}
}
public static function render_page() {
if (!current_user_can('manage_options')) {
return;
}
// Filters from query string
$search = sanitize_text_field($_GET['hp_search'] ?? '');
$filter_ip = sanitize_text_field($_GET['hp_ip'] ?? '');
$filter_form = sanitize_text_field($_GET['hp_form'] ?? '');
$paged = max(1, intval($_GET['paged'] ?? 1));
$per_page = self::PER_PAGE;
$offset = ($paged - 1) * $per_page;
$query_args = array_filter([
'ip' => $filter_ip,
'form' => $filter_form,
'search' => $search,
'per_page' => $per_page,
'offset' => $offset,
]);
$rows = SmartHoneypotDB::get_rows($query_args);
$total = SmartHoneypotDB::count($query_args);
$total_ever = SmartHoneypotDB::count();
$form_types = SmartHoneypotDB::get_form_types();
$total_pages = max(1, ceil($total / $per_page));
// Unique IPs total
global $wpdb;
$unique_ips = (int) $wpdb->get_var("SELECT COUNT(DISTINCT ip_address) FROM " . SmartHoneypotDB::table());
$today = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM " . SmartHoneypotDB::table() . " WHERE blocked_at >= CURDATE()"
);
$base_url = admin_url('admin.php?page=' . self::MENU_SLUG);
?>
<div class="wrap" id="hp-log-wrap">
<h1 class="wp-heading-inline">Honeypot Logs</h1>
<?php if (!empty($_GET['cleared'])): ?>
<div class="notice notice-success is-dismissible"><p>All logs have been cleared.</p></div>
<?php endif; ?>
<!-- Stats cards -->
<div class="hp-stats">
<div class="hp-stat-card">
<div class="hp-stat-num"><?= number_format($total_ever) ?></div>
<div class="hp-stat-lbl">Total Blocked</div>
</div>
<div class="hp-stat-card">
<div class="hp-stat-num"><?= number_format($today) ?></div>
<div class="hp-stat-lbl">Blocked Today</div>
</div>
<div class="hp-stat-card">
<div class="hp-stat-num"><?= number_format($unique_ips) ?></div>
<div class="hp-stat-lbl">Unique IPs</div>
</div>
<div class="hp-stat-card">
<div class="hp-stat-num"><?= count($form_types) ?></div>
<div class="hp-stat-lbl">Form Types Hit</div>
</div>
</div>
<!-- Filters -->
<form method="get" action="">
<input type="hidden" name="page" value="<?= esc_attr(self::MENU_SLUG) ?>">
<div class="hp-filters">
<input type="text" name="hp_search" placeholder="Search IP, UA, reason…"
value="<?= esc_attr($search) ?>" size="30">
<input type="text" name="hp_ip" placeholder="Filter by IP"
value="<?= esc_attr($filter_ip) ?>">
<select name="hp_form">
<option value="">All form types</option>
<?php foreach ($form_types as $ft): ?>
<option value="<?= esc_attr($ft) ?>" <?= selected($filter_form, $ft, false) ?>>
<?= esc_html($ft) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="button">Filter</button>
<?php if ($search || $filter_ip || $filter_form): ?>
<a href="<?= esc_url($base_url) ?>" class="button">Reset</a>
<?php endif; ?>
<span style="flex:1"></span>
<!-- Clear all logs -->
<form method="post" action="" style="display:inline"
onsubmit="return confirm('Delete ALL log entries permanently?');">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="clear_logs">
<button type="submit" class="button hp-clear-btn">Clear All Logs</button>
</form>
</div>
</form>
<p>Showing <strong><?= number_format($total) ?></strong> result<?= $total !== 1 ? 's' : '' ?>
(page <?= $paged ?> of <?= $total_pages ?>)</p>
<!-- Log table -->
<table class="hp-log-table widefat">
<thead>
<tr>
<th>#</th>
<th>Date / Time</th>
<th>IP Address</th>
<th>Form Type</th>
<th>Reason</th>
<th>URI</th>
<th>User Agent</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="7" style="text-align:center;padding:24px;color:#646970;">
No blocked attempts recorded yet.
</td></tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr>
<td><?= esc_html($row->id) ?></td>
<td style="white-space:nowrap"><?= esc_html($row->blocked_at) ?></td>
<td>
<code><?= esc_html($row->ip_address) ?></code><br>
<a href="<?= esc_url(add_query_arg(['page' => self::MENU_SLUG, 'hp_ip' => $row->ip_address], admin_url('admin.php'))) ?>"
style="font-size:11px">filter</a>
&nbsp;
<a href="https://ipinfo.io/<?= esc_attr(urlencode($row->ip_address)) ?>"
target="_blank" style="font-size:11px">lookup ↗</a>
</td>
<td><span class="hp-badge"><?= esc_html($row->form_type) ?></span></td>
<td><?= esc_html($row->reason) ?></td>
<td style="font-size:11px;word-break:break-all"><?= esc_html($row->request_uri) ?></td>
<td class="hp-ua"><?= esc_html($row->user_agent) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<?php if ($total_pages > 1): ?>
<div class="hp-pagination">
<?php if ($paged > 1): ?>
<a href="<?= esc_url(add_query_arg(array_merge($_GET, ['paged' => $paged - 1]))) ?>">← Prev</a>
<?php endif; ?>
<?php
$start = max(1, $paged - 3);
$end = min($total_pages, $paged + 3);
for ($p = $start; $p <= $end; $p++):
$url = esc_url(add_query_arg(array_merge($_GET, ['paged' => $p])));
if ($p === $paged): ?>
<span class="current"><?= $p ?></span>
<?php else: ?>
<a href="<?= $url ?>"><?= $p ?></a>
<?php endif;
endfor; ?>
<?php if ($paged < $total_pages): ?>
<a href="<?= esc_url(add_query_arg(array_merge($_GET, ['paged' => $paged + 1]))) ?>">Next →</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div><!-- /#hp-log-wrap -->
<?php
}
}
/* ======================================================================
* MAIN PLUGIN CLASS
* ====================================================================*/
class SmartHoneypotAntiSpam {
/** Honeypot text input name — looks like a real field bots want to fill */
@@ -25,23 +425,17 @@ class SmartHoneypotAntiSpam {
/** Timestamp field name */
private $time_name;
/** Minimum seconds before a form can be submitted */
/** Set before check_submission() so log_spam() knows which form was hit */
private $current_form_type = 'Unknown';
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 MAX_SUBMIT_TIME = 7200;
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';
@@ -50,7 +444,7 @@ class SmartHoneypotAntiSpam {
}
/* ------------------------------------------------------------------
* INIT — register all hooks
* INIT
* ----------------------------------------------------------------*/
public function init() {
if (is_admin()) {
@@ -58,72 +452,40 @@ class SmartHoneypotAntiSpam {
return;
}
// --- Inject honeypot into forms ---
// WordPress core
// Inject honeypot
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)
// Validate
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 ---
// CSS & JS
add_action('wp_head', [$this, 'print_css']);
add_action('wp_footer', [$this, 'print_js'], 99);
}
/* ------------------------------------------------------------------
* HONEYPOT FIELD HTML
* HONEYPOT HTML
* ----------------------------------------------------------------*/
private function get_honeypot_html() {
private function get_honeypot_html(): string {
$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>
@@ -138,7 +500,6 @@ class SmartHoneypotAntiSpam {
);
}
/** Echo the honeypot (for action hooks) */
public function echo_honeypot() {
echo $this->get_honeypot_html();
}
@@ -152,27 +513,13 @@ class SmartHoneypotAntiSpam {
}
return preg_replace_callback(
'/(<form\b[^>]*>)(.*?)(<\/form>)/is',
function ($m) {
return $m[1] . $m[2] . $this->get_honeypot_html() . $m[3];
},
fn($m) => $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
);
return preg_replace('/(<\/form>)/i', $this->get_honeypot_html() . '$1', $form, 1);
}
public function add_to_elementor_form($field, $instance) {
@@ -185,119 +532,89 @@ class SmartHoneypotAntiSpam {
public function filter_elementor_widget($content, $widget) {
if ($widget->get_name() === 'form') {
$content = preg_replace(
'/(<\/form>)/i',
$this->get_honeypot_html() . '$1',
$content,
1
);
$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
);
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
);
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).
* VALIDATION
* ----------------------------------------------------------------*/
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)');
if ($require_fields && !isset($_POST[$this->hp_name])) {
$this->log_spam('Honeypot field missing (direct POST)');
return false;
}
}
if (isset($_POST[$this->hp_name]) && $_POST[$this->hp_name] !== '') {
$this->log_spam('Honeypot field filled in');
$this->log_spam('Honeypot field was 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)');
if ($require_fields && empty($_POST[$this->token_name])) {
$this->log_spam('JS token absent (no JavaScript)');
return false;
}
// Validate token format: "timestamp|hash"
if (!$this->validate_js_token($_POST[$this->token_name])) {
$this->log_spam('JS token invalid');
if (!empty($_POST[$this->token_name]) && !$this->validate_js_token($_POST[$this->token_name])) {
$this->log_spam('JS token invalid or tampered');
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;
$diff = time() - intval($_POST[$this->time_name]);
if ($diff < self::MIN_SUBMIT_TIME) {
$this->log_spam("Submitted too fast ({$diff}s)");
$this->log_spam("Submitted too fast ({$diff}s — bot behaviour)");
return false;
}
if ($diff > self::MAX_SUBMIT_TIME) {
$this->log_spam("Timestamp too old ({$diff}s)");
$this->log_spam("Timestamp expired ({$diff}s old)");
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);
$expected = hash_hmac('sha256', $parts[0] . '|honeypot_js_proof', $this->secret);
return hash_equals(substr($expected, 0, 16), $parts[1]);
}
/** 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]);
foreach ([$this->hp_name, $this->token_name, $this->time_name] as $k) {
unset($_POST[$k], $_REQUEST[$k]);
}
}
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}");
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$uri = $_SERVER['REQUEST_URI'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
// Write to DB
SmartHoneypotDB::insert([
'ip' => $ip,
'form' => $this->current_form_type,
'reason' => $reason,
'uri' => $uri,
'ua' => $ua,
]);
// Also write to PHP error log for server-level monitoring
error_log("[Honeypot] {$reason} | form={$this->current_form_type} | ip={$ip} | uri={$uri}");
}
/* ------------------------------------------------------------------
@@ -311,7 +628,8 @@ class SmartHoneypotAntiSpam {
$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}");
$this->current_form_type .= ' (rate limited)';
$this->log_spam("Rate limit hit — {$count} attempts this hour from {$ip}");
return false;
}
set_transient($key, $count + 1, HOUR_IN_SECONDS);
@@ -319,72 +637,58 @@ class SmartHoneypotAntiSpam {
}
/* ------------------------------------------------------------------
* WOOCOMMERCE VALIDATION
* WOOCOMMERCE
* ----------------------------------------------------------------*/
public function validate_wc_registration($errors, $username, $password, $email) {
$this->current_form_type = 'WooCommerce Registration';
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')
);
$errors->add('honeypot_spam', __('<strong>Error</strong>: Registration blocked. 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')
);
$errors->add('honeypot_rate', __('<strong>Error</strong>: Too many registration attempts. Try again later.', 'smart-honeypot'));
}
return $errors;
}
public function validate_wc_login($errors, $username, $password) {
$this->current_form_type = 'WooCommerce Login';
if (!$this->check_submission(true)) {
$errors->add(
'honeypot_spam',
__('<strong>Error</strong>: Login blocked. Please enable JavaScript and try again.', 'smart-honeypot')
);
$errors->add('honeypot_spam', __('<strong>Error</strong>: Login blocked. Enable JavaScript and try again.', 'smart-honeypot'));
}
return $errors;
}
public function validate_wc_checkout($data, $errors) {
$this->current_form_type = 'WooCommerce Checkout';
if (!$this->check_submission(false)) {
$errors->add(
'honeypot_spam',
__('Order could not be processed. Please try again.', 'smart-honeypot')
);
$errors->add('honeypot_spam', __('Order could not be processed. Please try again.', 'smart-honeypot'));
}
}
/* ------------------------------------------------------------------
* WORDPRESS CORE VALIDATION
* WORDPRESS CORE
* ----------------------------------------------------------------*/
public function validate_wp_registration($errors, $sanitized_user_login, $user_email) {
public function validate_wp_registration($errors, $login, $email) {
$this->current_form_type = 'WP Registration';
if (!$this->check_submission(true)) {
$errors->add(
'honeypot_spam',
__('<strong>Error</strong>: Registration blocked. Please enable JavaScript and try again.', 'smart-honeypot')
);
$errors->add('honeypot_spam', __('<strong>Error</strong>: Registration blocked. 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')
);
$errors->add('honeypot_rate', __('<strong>Error</strong>: Too many attempts. 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;
}
$this->current_form_type = 'Comment Form';
if (!$this->check_submission(true)) {
wp_die(
__('Comment blocked as spam. Please enable JavaScript and try again.', 'smart-honeypot'),
__('Comment blocked as spam. Enable JavaScript and try again.', 'smart-honeypot'),
__('Spam Detected', 'smart-honeypot'),
['response' => 403, 'back_link' => true]
);
@@ -393,29 +697,26 @@ class SmartHoneypotAntiSpam {
}
/* ------------------------------------------------------------------
* ELEMENTOR VALIDATION
* ELEMENTOR
* ----------------------------------------------------------------*/
public function validate_elementor_form($record, $ajax_handler) {
$this->current_form_type = 'Elementor Form';
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)
* GENERIC CATCH-ALL
* ----------------------------------------------------------------*/
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
// Skip forms handled by specific hooks
if (
isset($_POST['woocommerce-register-nonce']) ||
isset($_POST['woocommerce-login-nonce']) ||
@@ -426,12 +727,12 @@ class SmartHoneypotAntiSpam {
return;
}
$this->current_form_type = 'Generic Form';
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());
wp_safe_redirect(wp_get_referer() ?: home_url());
exit;
}
}
@@ -440,14 +741,11 @@ class SmartHoneypotAntiSpam {
* 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>';
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
* JS — HMAC token via SubtleCrypto
* ----------------------------------------------------------------*/
public function print_js() {
$secret = esc_js($this->secret);
@@ -457,51 +755,29 @@ class SmartHoneypotAntiSpam {
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 computeToken(ts,secret){
var msg=ts+'|honeypot_js_proof',enc=new TextEncoder();
return crypto.subtle.importKey('raw',enc.encode(secret),{name:'HMAC',hash:'SHA-256'},false,['sign'])
.then(function(k){return crypto.subtle.sign('HMAC',k,enc.encode(msg));})
.then(function(s){
var h=Array.from(new Uint8Array(s)).map(function(b){return b.toString(16).padStart(2,'0')}).join('');
return h.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;
});
function fillTokens(){
document.querySelectorAll('input[name="{$token_name}"]').forEach(function(inp){
var p=inp.closest('div')||inp.parentElement;
var tsInp=p?p.querySelector('input[name="{$time_name}"]'):null;
var ts=tsInp?tsInp.value:String(Math.floor(Date.now()/1000));
computeToken(ts,'{$secret}').then(function(h){inp.value=ts+'|'+h;});
});
}
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});
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fillTokens);}else{fillTokens();}
new MutationObserver(function(mm){
mm.forEach(function(m){m.addedNodes.forEach(function(n){
if(n.nodeType===1&&n.querySelector&&n.querySelector('input[name="{$token_name}"]'))fillTokens();
});});
}).observe(document.body,{childList:true,subtree:true});
})();
</script>
JSBLOCK;
@@ -513,30 +789,41 @@ JSBLOCK;
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>
<p><strong>Honeypot Fields</strong> is now active. All forms are protected. <a href="' .
esc_url(admin_url('admin.php?page=honeypot-logs')) . '">View logs →</a></p>
</div>';
delete_transient('smart_honeypot_activated');
}
}
}
// Boot
/* ======================================================================
* BOOT
* ====================================================================*/
define('HP_PLUGIN_FILE', __FILE__);
add_action('plugins_loaded', function () {
// Run DB upgrade if needed
if ((int) get_option(SmartHoneypotDB::TABLE_VERSION_OPTION) < SmartHoneypotDB::TABLE_VERSION) {
SmartHoneypotDB::install();
}
new SmartHoneypotAntiSpam();
SmartHoneypotAdmin::register();
});
// Activation
register_activation_hook(__FILE__, function () {
SmartHoneypotDB::install();
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;
// Auto-prune logs older than 90 days (runs once daily)
add_action('hp_daily_cleanup', function () {
SmartHoneypotDB::delete_older_than_days(90);
});
if (!wp_next_scheduled('hp_daily_cleanup')) {
wp_schedule_event(time(), 'daily', 'hp_daily_cleanup');
}