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:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user