From bd5a67b57ffe1d78aad39f111afb29e35b683506 Mon Sep 17 00:00:00 2001 From: Malin Date: Mon, 9 Mar 2026 19:26:23 +0100 Subject: [PATCH] fix: full IPs, top attacked form banner, Bearer token auth on /submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.js: - Remove maskIP() — store full IPs as submitted (sanitizeIP trims/truncates only) - Add requireToken() middleware with constant-time comparison (timingSafeEqual) using 128-byte padded buffers to prevent length-based timing leaks - API_TOKEN env var — if unset the endpoint stays open (dev mode); set it in prod - /api/v1/submit now requires Authorization: Bearer docker-compose.yml / .env.example: - Expose API_TOKEN env var with clear comment index.html: - Add red-bordered 'MOST ATTACKED FORM (30D)' banner between stats and content grid showing form name, hit count, and % of all 30d blocks - Widen live feed IP column 90px → 130px to fit full IPv4 addresses - Remove 'ALL DATA IS ANONYMISED' from footer (IPs are full now) honeypot-fields.php: - SmartHoneypotAPIClient: add api_token to defaults + send Authorization header - save_api_settings: persist api_token field - Settings tab: add password input for API token with description --- api/.env.example | 3 +++ api/docker-compose.yml | 1 + api/public/index.html | 54 ++++++++++++++++++++++++++++++++++++++++-- api/server.js | 44 ++++++++++++++++++++++------------ honeypot-fields.php | 22 +++++++++++++++-- 5 files changed, 105 insertions(+), 19 deletions(-) diff --git a/api/.env.example b/api/.env.example index 79b0dbb..de0c0b1 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,3 +1,6 @@ PORT=3000 DB_PATH=/data/honeypot.db NODE_ENV=production +# Set a strong random token — all WP sites must send this as: Authorization: Bearer +# Leave empty to run in open mode (dev only) +API_TOKEN=change-me-to-a-long-random-string diff --git a/api/docker-compose.yml b/api/docker-compose.yml index f6caf3e..197ac04 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -11,6 +11,7 @@ services: - PORT=3000 - DB_PATH=/data/honeypot.db - NODE_ENV=production + - API_TOKEN=${API_TOKEN:-change-me-to-a-long-random-string} healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/v1/health"] interval: 30s diff --git a/api/public/index.html b/api/public/index.html index 1444e83..32c5813 100644 --- a/api/public/index.html +++ b/api/public/index.html @@ -211,7 +211,7 @@ main { padding: 14px 16px; max-width: 1700px; margin: 0 auto; } .feed-row { display: grid; - grid-template-columns: 62px 90px auto; + grid-template-columns: 62px 130px auto; gap: 6px; border-bottom: 1px solid var(--muted); padding: 1px 0; @@ -272,6 +272,39 @@ footer { gap: 6px; } +/* ── Top target banner ──────────────────────────────────────────────── */ +#top-target { + background: var(--bg2); + border: 1px solid #2a0000; + border-left: 3px solid var(--red); + padding: 10px 16px; + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} +#top-target .tt-label { + font-size: 10px; + letter-spacing: 3px; + color: var(--dim); +} +#top-target .tt-form { + font-size: 15px; + font-weight: bold; + color: var(--red); + text-shadow: 0 0 10px var(--red); + letter-spacing: 1px; +} +#top-target .tt-hits { + font-size: 11px; + color: var(--amber); +} +#top-target .tt-pct { + font-size: 11px; + color: var(--dim); +} + /* ── Responsive ─────────────────────────────────────────────────────── */ @media (max-width: 1100px) { .stats-row { grid-template-columns: repeat(3, 1fr); } @@ -321,6 +354,14 @@ footer { + +
+ ▶ MOST ATTACKED FORM (30D): + + + +
+
@@ -402,7 +443,7 @@ footer {
- HONEYPOT NETWORK MONITOR // ANONYMOUS THREAT INTELLIGENCE // ALL DATA IS ANONYMISED + HONEYPOT NETWORK MONITOR // CENTRALIZED THREAT INTELLIGENCE REFRESHED: --
@@ -602,6 +643,15 @@ async function fetchStats() { renderBars(document.getElementById('bars-reasons'), s.top_reasons); renderAttackers(s.top_ips); + // Top attacked form banner + if (s.top_forms && s.top_forms.length) { + const top = s.top_forms[0]; + document.getElementById('tt-form').textContent = top.form_type; + document.getElementById('tt-hits').textContent = `${top.hits.toLocaleString()} hits`; + const pct = s.last_30d > 0 ? Math.round(top.hits / s.last_30d * 100) : 0; + document.getElementById('tt-pct').textContent = `(${pct}% of all blocks)`; + } + window._hourly = s.hourly; drawChart(s.hourly); diff --git a/api/server.js b/api/server.js index 6aeb140..ff4a5fa 100644 --- a/api/server.js +++ b/api/server.js @@ -1,8 +1,9 @@ 'use strict'; -const express = require('express'); -const Database = require('better-sqlite3'); -const path = require('path'); +const express = require('express'); +const Database = require('better-sqlite3'); +const path = require('path'); +const { timingSafeEqual } = require('crypto'); const app = express(); const PORT = Number(process.env.PORT) || 3000; @@ -35,17 +36,30 @@ DB.exec(` CREATE INDEX IF NOT EXISTS idx_site ON blocks(site_id); `); +// ── Auth token ──────────────────────────────────────────────────────────────── + +const API_TOKEN = (process.env.API_TOKEN || '').trim(); + +function requireToken(req, res, next) { + if (!API_TOKEN) return next(); // dev: no token set = open + + const auth = req.headers['authorization'] || ''; + const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + + // Constant-time comparison — pad both to 128 bytes to avoid length leaks + const a = Buffer.alloc(128); Buffer.from(token).copy(a, 0, 0, 128); + const b = Buffer.alloc(128); Buffer.from(API_TOKEN).copy(b, 0, 0, 128); + + if (!timingSafeEqual(a, b) || token !== API_TOKEN) { + return res.status(403).json({ error: 'Forbidden' }); + } + next(); +} + // ── Helpers ─────────────────────────────────────────────────────────────────── -function maskIP(ip = '') { - ip = String(ip).trim(); - if (!ip) return 'x.x.x.x'; - if (ip.includes(':')) { // IPv6 — keep first 2 groups - const p = ip.split(':'); - return `${p[0]||'x'}:${p[1]||'x'}:…:x`; - } - const p = ip.split('.'); // IPv4 — keep first 2 octets - return p.length === 4 ? `${p[0]}.${p[1]}.x.x` : 'x.x.x.x'; +function sanitizeIP(ip = '') { + return String(ip).trim().slice(0, 45) || '?'; } const UA_MAP = [ @@ -168,7 +182,7 @@ const insertBatch = DB.transaction((siteId, blocks) => { const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now; stmtIns.run( ts, siteId, - maskIP(b.ip), + sanitizeIP(b.ip), String(b.form_type || '').slice(0, 100), String(b.reason || '').slice(0, 255), parseUA(b.user_agent || '') @@ -182,8 +196,8 @@ const insertBatch = DB.transaction((siteId, blocks) => { app.use(express.json({ limit: '128kb' })); app.use(express.static(path.join(__dirname, 'public'))); -// Submit blocks from a WordPress site -app.post('/api/v1/submit', (req, res) => { +// Submit blocks from a WordPress site (token-protected) +app.post('/api/v1/submit', requireToken, (req, res) => { const clientIP = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || ''; diff --git a/honeypot-fields.php b/honeypot-fields.php index fea0236..e95ea1e 100644 --- a/honeypot-fields.php +++ b/honeypot-fields.php @@ -162,6 +162,7 @@ class SmartHoneypotAPIClient { return [ 'enabled' => false, 'api_url' => '', + 'api_token' => '', 'last_sync' => 0, 'sent_total' => 0, ]; @@ -199,12 +200,17 @@ class SmartHoneypotAPIClient { $batch = array_splice($queue, 0, self::BATCH_SIZE); $site_hash = hash('sha256', home_url()); + $headers = ['Content-Type' => 'application/json']; + if (!empty($s['api_token'])) { + $headers['Authorization'] = 'Bearer ' . $s['api_token']; + } + $response = wp_remote_post( trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit', [ 'timeout' => 15, 'blocking' => true, - 'headers' => ['Content-Type' => 'application/json'], + 'headers' => $headers, 'body' => wp_json_encode([ 'site_hash' => $site_hash, 'blocks' => $batch, @@ -308,7 +314,8 @@ class SmartHoneypotAdmin { $current = SmartHoneypotAPIClient::settings(); $new = [ 'enabled' => !empty($_POST['hp_api_enabled']), - 'api_url' => esc_url_raw(trim($_POST['hp_api_url'] ?? '')), + 'api_url' => esc_url_raw(trim($_POST['hp_api_url'] ?? '')), + 'api_token' => sanitize_text_field($_POST['hp_api_token'] ?? ''), 'last_sync' => $current['last_sync'], 'sent_total' => $current['sent_total'], ]; @@ -508,6 +515,17 @@ class SmartHoneypotAdmin {

Base URL of your Honeypot API Docker container.

+ + API Token + + +

+ Must match the API_TOKEN set in your Docker container's environment. + Leave empty only if the API is running without a token (not recommended). +

+ +