fix: CF7 bypass, auto-flush, layout, contrast, IP geo v2.4.0

CF7:
- Add wpcf7_spam filter registered before is_admin() early-return so
  CF7 AJAX submissions (admin-ajax.php) are properly validated
- Exclude CF7 posts from generic catch-all (prevent double-checking)

Auto-flush:
- Add maybe_flush_overdue() with 5-min transient lock, hooked to
  shutdown action so every PHP request can trigger a flush if overdue
- No longer depends solely on WP-Cron firing

Dashboard layout:
- Top Attackers moved into right column below live feed
- Viewport-fill layout: body/main use flex+overflow:hidden so content
  stays in view; left col scrolls independently if needed
- Feed panel takes flex:1, attackers panel capped at 260px

Colors:
- --dim: #006600 → #44bb77 (legible secondary text, ~5:1 contrast)
- --dim2: #228844 added for slightly darker secondary use
- --muted kept dark for backgrounds only; border lightened slightly

IP geo (server-side, async, non-blocking):
- country + asn columns added to blocks table (migration-safe)
- enrichIP() calls ip-api.com free HTTP API per unique IP, cached 1h
- Background job enriches historic rows missing country (5 per 20s)
- Stats and live feed now include country code + ASN
- Dashboard shows country flag emoji in feed rows and attackers table
- Full AS name shown as tooltip on ASN column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 07:57:16 +01:00
parent 01a15007cb
commit 07b5025b0b
3 changed files with 437 additions and 414 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const http = require('http');
const { timingSafeEqual } = require('crypto');
const app = express();
@@ -23,7 +24,9 @@ DB.exec(`
ip_masked TEXT NOT NULL DEFAULT '',
form_type TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
ua_family TEXT NOT NULL DEFAULT ''
ua_family TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS sites (
site_id TEXT PRIMARY KEY,
@@ -36,6 +39,10 @@ DB.exec(`
CREATE INDEX IF NOT EXISTS idx_site ON blocks(site_id);
`);
// Add country/asn columns to existing tables (migration — ignored if already present)
try { DB.exec(`ALTER TABLE blocks ADD COLUMN country TEXT NOT NULL DEFAULT ''`); } catch {}
try { DB.exec(`ALTER TABLE blocks ADD COLUMN asn TEXT NOT NULL DEFAULT ''`); } catch {}
// ── Auth token ────────────────────────────────────────────────────────────────
const API_TOKEN = (process.env.API_TOKEN || '').trim();
@@ -90,6 +97,51 @@ function parseUA(ua = '') {
return ua.length ? 'Other' : 'No UA';
}
// ── IP geo-enrichment (ip-api.com free tier, HTTP) ────────────────────────────
const stmtEnrich = DB.prepare('UPDATE blocks SET country=?, asn=? WHERE id=?');
const enrichCache = new Map(); // ip -> expiry ms (deduplicate within 1h)
function isPrivateIP(ip) {
return /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|127\.|::1$|fc|fd)/.test(ip);
}
function enrichIP(rowId, ip) {
if (!ip || ip === '?' || isPrivateIP(ip)) return;
const now = Date.now();
if ((enrichCache.get(ip) || 0) > now) return; // recently done
enrichCache.set(ip, now + 3_600_000); // cache 1 h
http.get(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,countryCode,as`,
{ timeout: 5000 },
res => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const j = JSON.parse(data);
if (j.status === 'success') {
stmtEnrich.run(
(j.countryCode || '').slice(0, 2),
(j.as || '').slice(0, 50),
rowId
);
}
} catch {}
});
}
).on('error', () => enrichCache.delete(ip)); // retry next time
}
// Background enrichment: fill any rows missing country (e.g. from history import)
const stmtUnenriched = DB.prepare(
"SELECT id, ip_masked FROM blocks WHERE country='' AND ip_masked != '' AND ip_masked != '?' LIMIT 5"
);
setInterval(() => {
for (const row of stmtUnenriched.all()) enrichIP(row.id, row.ip_masked);
}, 20_000);
// ── In-memory rate limiter ────────────────────────────────────────────────────
const rl = new Map();
@@ -117,7 +169,7 @@ function getStats() {
last_30d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 2592000).n,
total_sites: DB.prepare('SELECT COUNT(*) n FROM sites').get().n,
top_ips: DB.prepare(`
SELECT ip_masked ip, COUNT(*) hits
SELECT ip_masked ip, country, asn, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY ip_masked ORDER BY hits DESC LIMIT 10
`).all(now - 2592000),
@@ -137,7 +189,7 @@ function getStats() {
GROUP BY ua_family ORDER BY hits DESC LIMIT 8
`).all(now - 2592000),
recent: DB.prepare(`
SELECT received_at, ip_masked ip, form_type, reason, ua_family
SELECT received_at, ip_masked ip, country, form_type, reason, ua_family
FROM blocks ORDER BY id DESC LIMIT 40
`).all(),
hourly: DB.prepare(`
@@ -167,7 +219,7 @@ setInterval(() => {
// ── Prepared statements ───────────────────────────────────────────────────────
const stmtIns = DB.prepare(
'INSERT INTO blocks (received_at, site_id, ip_masked, form_type, reason, ua_family) VALUES (?,?,?,?,?,?)'
'INSERT INTO blocks (received_at, site_id, ip_masked, form_type, reason, ua_family, country, asn) VALUES (?,?,?,?,?,?,?,?)'
);
const stmtSite = DB.prepare(`
INSERT INTO sites (site_id, first_seen, last_seen, block_count) VALUES (?,?,?,?)
@@ -178,17 +230,21 @@ const stmtSite = DB.prepare(`
const insertBatch = DB.transaction((siteId, blocks) => {
const now = Math.floor(Date.now() / 1000);
const ids = [];
for (const b of blocks) {
const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
stmtIns.run(
ts, siteId,
sanitizeIP(b.ip),
const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
const ip = sanitizeIP(b.ip);
const r = stmtIns.run(
ts, siteId, ip,
String(b.form_type || '').slice(0, 100),
String(b.reason || '').slice(0, 255),
parseUA(b.user_agent || '')
parseUA(b.user_agent || ''),
'', '' // country / asn filled async
);
ids.push({ id: Number(r.lastInsertRowid), ip });
}
stmtSite.run(siteId, now, now, blocks.length);
return ids;
});
// ── Express routes ────────────────────────────────────────────────────────────
@@ -215,8 +271,10 @@ app.post('/api/v1/submit', requireToken, (req, res) => {
}
try {
insertBatch(site_hash.slice(0, 20), blocks);
const ids = insertBatch(site_hash.slice(0, 20), blocks);
_cache = null; // invalidate stats cache
// Enrich IPs asynchronously without blocking the response
setImmediate(() => ids.forEach(({ id, ip }) => enrichIP(id, ip)));
res.json({ ok: true, received: blocks.length });
} catch (e) {
console.error('[submit]', e.message);

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.3.0
* Version: 2.4.0
* Author: Malin
* Author URI: https://malin.ro
* License: GPL v2 or later
@@ -378,6 +378,20 @@ class SmartHoneypotAPIClient {
return count((array) get_option(self::OPT_QUEUE, []));
}
/**
* Fallback flush triggered on every PHP shutdown.
* A transient lock ensures we attempt at most once per 5 minutes
* regardless of traffic volume, so WP-Cron is not the sole trigger.
*/
public static function maybe_flush_overdue(): void {
$s = self::settings();
if (!$s['enabled'] || empty($s['api_url'])) return;
if (self::queue_size() === 0) return;
if (get_transient('hp_flush_lock')) return;
set_transient('hp_flush_lock', 1, 300); // 5-min lock
self::flush();
}
/**
* Returns the API token.
* Checks the HP_API_TOKEN constant (defined in wp-config.php) first,
@@ -1181,6 +1195,10 @@ class SmartHoneypotAntiSpam {
/* ── Init ──────────────────────────────────────────────────────── */
public function init() {
// CF7 submits via admin-ajax.php where is_admin()=true, so hook here
// before the early return so spam validation still runs.
add_filter('wpcf7_spam', [$this, 'validate_cf7_spam'], 10, 2);
if (is_admin()) {
add_action('admin_notices', [$this, 'activation_notice']);
return;
@@ -1423,6 +1441,13 @@ class SmartHoneypotAntiSpam {
}
}
/* ── Contact Form 7 ────────────────────────────────────────────── */
public function validate_cf7_spam($spam, $submission) {
if ($spam) return true; // already flagged
$this->current_form_type = 'Contact Form 7';
return !$this->check_submission(true);
}
/* ── Generic catch-all ─────────────────────────────────────────── */
public function validate_generic_post() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -1436,6 +1461,7 @@ class SmartHoneypotAntiSpam {
isset($_POST['woocommerce-login-nonce']) ||
isset($_POST['woocommerce-process-checkout-nonce']) ||
isset($_POST['comment_post_ID']) ||
isset($_POST['_wpcf7']) || // CF7: handled by wpcf7_spam filter
(isset($_POST['action']) && $_POST['action'] === 'elementor_pro_forms_send_form')
) {
return;
@@ -1514,6 +1540,8 @@ add_action('plugins_loaded', function () {
}
new SmartHoneypotAntiSpam();
SmartHoneypotAdmin::register();
// Fallback: flush queue on every request's shutdown (rate-limited via transient)
add_action('shutdown', ['SmartHoneypotAPIClient', 'maybe_flush_overdue']);
});
// Custom cron interval (5 minutes)