Files
HoneypotFields/api/server.js
Malin 07b5025b0b 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>
2026-03-10 07:57:16 +01:00

310 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const http = require('http');
const { timingSafeEqual } = require('crypto');
const app = express();
const PORT = Number(process.env.PORT) || 3000;
const DB = new Database(process.env.DB_PATH || '/data/honeypot.db');
// ── Database setup ────────────────────────────────────────────────────────────
DB.pragma('journal_mode = WAL');
DB.pragma('synchronous = NORMAL');
DB.pragma('cache_size = -8000');
DB.exec(`
CREATE TABLE IF NOT EXISTS blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at INTEGER NOT NULL DEFAULT (unixepoch()),
site_id TEXT NOT NULL DEFAULT '',
ip_masked TEXT NOT NULL DEFAULT '',
form_type TEXT NOT NULL DEFAULT '',
reason 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,
first_seen INTEGER NOT NULL DEFAULT (unixepoch()),
last_seen INTEGER NOT NULL DEFAULT (unixepoch()),
block_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recv ON blocks(received_at DESC);
CREATE INDEX IF NOT EXISTS idx_ip ON blocks(ip_masked);
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();
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 sanitizeIP(ip = '') {
return String(ip).trim().slice(0, 45) || '?';
}
const UA_MAP = [
[/curl\//i, 'curl'],
[/python-requests|python\//i, 'Python'],
[/go-http-client/i, 'Go'],
[/wget\//i, 'Wget'],
[/java\//i, 'Java'],
[/ruby/i, 'Ruby'],
[/perl\//i, 'Perl'],
[/php\//i, 'PHP'],
[/scrapy/i, 'Scrapy'],
[/postman/i, 'Postman'],
[/axios/i, 'Axios'],
[/node-fetch|node\.js/i, 'Node.js'],
[/headlesschrome|phantomjs/i, 'Headless Browser'],
[/(bot|crawler|spider|slurp)/i, 'Bot/Crawler'],
[/chrome/i, 'Chrome'],
[/firefox/i, 'Firefox'],
[/safari/i, 'Safari'],
[/edge|edg\//i, 'Edge'],
[/opera|opr\//i, 'Opera'],
[/msie|trident/i, 'IE'],
];
function parseUA(ua = '') {
for (const [re, label] of UA_MAP) if (re.test(ua)) return label;
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();
setInterval(() => { const n = Date.now(); for (const [k, v] of rl) if (n > v.r) rl.delete(k); }, 30_000);
function allowed(ip, max = 30, win = 60_000) {
const n = Date.now();
let e = rl.get(ip);
if (!e || n > e.r) { e = { c: 0, r: n + win }; rl.set(ip, e); }
return ++e.c <= max;
}
// ── Stats cache (30s TTL) ─────────────────────────────────────────────────────
let _cache = null, _cacheTs = 0;
function getStats() {
if (_cache && Date.now() - _cacheTs < 30_000) return _cache;
const now = Math.floor(Date.now() / 1000);
_cache = {
total: DB.prepare('SELECT COUNT(*) n FROM blocks').get().n,
today: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 86400).n,
last_7d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 604800).n,
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, country, asn, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY ip_masked ORDER BY hits DESC LIMIT 10
`).all(now - 2592000),
top_forms: DB.prepare(`
SELECT form_type, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY form_type ORDER BY hits DESC LIMIT 8
`).all(now - 2592000),
top_reasons: DB.prepare(`
SELECT reason, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY reason ORDER BY hits DESC LIMIT 8
`).all(now - 2592000),
top_ua: DB.prepare(`
SELECT ua_family, COUNT(*) hits
FROM blocks WHERE received_at > ?
GROUP BY ua_family ORDER BY hits DESC LIMIT 8
`).all(now - 2592000),
recent: DB.prepare(`
SELECT received_at, ip_masked ip, country, form_type, reason, ua_family
FROM blocks ORDER BY id DESC LIMIT 40
`).all(),
hourly: DB.prepare(`
SELECT (received_at / 3600) * 3600 h, COUNT(*) n
FROM blocks WHERE received_at > ?
GROUP BY h ORDER BY h ASC
`).all(now - 86400),
};
_cacheTs = Date.now();
return _cache;
}
// ── SSE live stream ───────────────────────────────────────────────────────────
const sseClients = new Set();
let lastId = DB.prepare('SELECT MAX(id) id FROM blocks').get().id || 0;
setInterval(() => {
if (!sseClients.size) return;
const rows = DB.prepare('SELECT * FROM blocks WHERE id > ? ORDER BY id ASC LIMIT 20').all(lastId);
if (!rows.length) return;
lastId = rows.at(-1).id;
const msg = `data: ${JSON.stringify(rows)}\n\n`;
for (const r of sseClients) { try { r.write(msg); } catch { sseClients.delete(r); } }
}, 2000);
// ── Prepared statements ───────────────────────────────────────────────────────
const stmtIns = DB.prepare(
'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 (?,?,?,?)
ON CONFLICT(site_id) DO UPDATE SET
last_seen = excluded.last_seen,
block_count = block_count + excluded.block_count
`);
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;
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 || ''),
'', '' // country / asn filled async
);
ids.push({ id: Number(r.lastInsertRowid), ip });
}
stmtSite.run(siteId, now, now, blocks.length);
return ids;
});
// ── Express routes ────────────────────────────────────────────────────────────
app.use(express.json({ limit: '128kb' }));
app.use(express.static(path.join(__dirname, 'public')));
// 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 || '';
if (!allowed(clientIP)) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
const { site_hash, blocks } = req.body || {};
if (!site_hash || typeof site_hash !== 'string' || site_hash.length < 8) {
return res.status(400).json({ error: 'Invalid site_hash' });
}
if (!Array.isArray(blocks) || !blocks.length || blocks.length > 50) {
return res.status(400).json({ error: 'blocks must be array of 150 items' });
}
try {
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);
res.status(500).json({ error: 'Internal error' });
}
});
// Public aggregated stats
app.get('/api/v1/stats', (_, res) => res.json(getStats()));
// SSE live feed
app.get('/api/v1/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write(':\n\n'); // flush headers
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
});
// Health check
app.get('/api/v1/health', (_, res) =>
res.json({ ok: true, uptime: process.uptime(), sse_clients: sseClients.size })
);
app.listen(PORT, '0.0.0.0', () => {
console.log(`[honeypot-api] listening on :${PORT}`);
console.log(`[honeypot-api] db: ${process.env.DB_PATH || '/data/honeypot.db'}`);
});