fix: full IPs, top attacked form banner, Bearer token auth on /submit
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 <token> 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
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
DB_PATH=/data/honeypot.db
|
DB_PATH=/data/honeypot.db
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
# Set a strong random token — all WP sites must send this as: Authorization: Bearer <token>
|
||||||
|
# Leave empty to run in open mode (dev only)
|
||||||
|
API_TOKEN=change-me-to-a-long-random-string
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
- DB_PATH=/data/honeypot.db
|
- DB_PATH=/data/honeypot.db
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- API_TOKEN=${API_TOKEN:-change-me-to-a-long-random-string}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/v1/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/v1/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ main { padding: 14px 16px; max-width: 1700px; margin: 0 auto; }
|
|||||||
|
|
||||||
.feed-row {
|
.feed-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 62px 90px auto;
|
grid-template-columns: 62px 130px auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
border-bottom: 1px solid var(--muted);
|
border-bottom: 1px solid var(--muted);
|
||||||
padding: 1px 0;
|
padding: 1px 0;
|
||||||
@@ -272,6 +272,39 @@ footer {
|
|||||||
gap: 6px;
|
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 ─────────────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────────────── */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.stats-row { grid-template-columns: repeat(3, 1fr); }
|
.stats-row { grid-template-columns: repeat(3, 1fr); }
|
||||||
@@ -321,6 +354,14 @@ footer {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Most attacked form ────────────────────────────────────────── -->
|
||||||
|
<div id="top-target">
|
||||||
|
<span class="tt-label">▶ MOST ATTACKED FORM (30D):</span>
|
||||||
|
<span class="tt-form" id="tt-form">—</span>
|
||||||
|
<span class="tt-hits" id="tt-hits"></span>
|
||||||
|
<span class="tt-pct" id="tt-pct"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Content grid ───────────────────────────────────────────────── -->
|
<!-- ── Content grid ───────────────────────────────────────────────── -->
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
|
|
||||||
@@ -402,7 +443,7 @@ footer {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>HONEYPOT NETWORK MONITOR // ANONYMOUS THREAT INTELLIGENCE // ALL DATA IS ANONYMISED</span>
|
<span>HONEYPOT NETWORK MONITOR // CENTRALIZED THREAT INTELLIGENCE</span>
|
||||||
<span>REFRESHED: <span id="last-update">--</span></span>
|
<span>REFRESHED: <span id="last-update">--</span></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -602,6 +643,15 @@ async function fetchStats() {
|
|||||||
renderBars(document.getElementById('bars-reasons'), s.top_reasons);
|
renderBars(document.getElementById('bars-reasons'), s.top_reasons);
|
||||||
renderAttackers(s.top_ips);
|
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;
|
window._hourly = s.hourly;
|
||||||
drawChart(s.hourly);
|
drawChart(s.hourly);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { timingSafeEqual } = require('crypto');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.PORT) || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
@@ -35,17 +36,30 @@ DB.exec(`
|
|||||||
CREATE INDEX IF NOT EXISTS idx_site ON blocks(site_id);
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function maskIP(ip = '') {
|
function sanitizeIP(ip = '') {
|
||||||
ip = String(ip).trim();
|
return String(ip).trim().slice(0, 45) || '?';
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UA_MAP = [
|
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;
|
const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
|
||||||
stmtIns.run(
|
stmtIns.run(
|
||||||
ts, siteId,
|
ts, siteId,
|
||||||
maskIP(b.ip),
|
sanitizeIP(b.ip),
|
||||||
String(b.form_type || '').slice(0, 100),
|
String(b.form_type || '').slice(0, 100),
|
||||||
String(b.reason || '').slice(0, 255),
|
String(b.reason || '').slice(0, 255),
|
||||||
parseUA(b.user_agent || '')
|
parseUA(b.user_agent || '')
|
||||||
@@ -182,8 +196,8 @@ const insertBatch = DB.transaction((siteId, blocks) => {
|
|||||||
app.use(express.json({ limit: '128kb' }));
|
app.use(express.json({ limit: '128kb' }));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
// Submit blocks from a WordPress site
|
// Submit blocks from a WordPress site (token-protected)
|
||||||
app.post('/api/v1/submit', (req, res) => {
|
app.post('/api/v1/submit', requireToken, (req, res) => {
|
||||||
const clientIP = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
const clientIP = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
||||||
|| req.socket.remoteAddress || '';
|
|| req.socket.remoteAddress || '';
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ class SmartHoneypotAPIClient {
|
|||||||
return [
|
return [
|
||||||
'enabled' => false,
|
'enabled' => false,
|
||||||
'api_url' => '',
|
'api_url' => '',
|
||||||
|
'api_token' => '',
|
||||||
'last_sync' => 0,
|
'last_sync' => 0,
|
||||||
'sent_total' => 0,
|
'sent_total' => 0,
|
||||||
];
|
];
|
||||||
@@ -199,12 +200,17 @@ class SmartHoneypotAPIClient {
|
|||||||
$batch = array_splice($queue, 0, self::BATCH_SIZE);
|
$batch = array_splice($queue, 0, self::BATCH_SIZE);
|
||||||
$site_hash = hash('sha256', home_url());
|
$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(
|
$response = wp_remote_post(
|
||||||
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
|
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
|
||||||
[
|
[
|
||||||
'timeout' => 15,
|
'timeout' => 15,
|
||||||
'blocking' => true,
|
'blocking' => true,
|
||||||
'headers' => ['Content-Type' => 'application/json'],
|
'headers' => $headers,
|
||||||
'body' => wp_json_encode([
|
'body' => wp_json_encode([
|
||||||
'site_hash' => $site_hash,
|
'site_hash' => $site_hash,
|
||||||
'blocks' => $batch,
|
'blocks' => $batch,
|
||||||
@@ -308,7 +314,8 @@ class SmartHoneypotAdmin {
|
|||||||
$current = SmartHoneypotAPIClient::settings();
|
$current = SmartHoneypotAPIClient::settings();
|
||||||
$new = [
|
$new = [
|
||||||
'enabled' => !empty($_POST['hp_api_enabled']),
|
'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'],
|
'last_sync' => $current['last_sync'],
|
||||||
'sent_total' => $current['sent_total'],
|
'sent_total' => $current['sent_total'],
|
||||||
];
|
];
|
||||||
@@ -508,6 +515,17 @@ class SmartHoneypotAdmin {
|
|||||||
<p class="description">Base URL of your Honeypot API Docker container.</p>
|
<p class="description">Base URL of your Honeypot API Docker container.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>API Token</th>
|
||||||
|
<td>
|
||||||
|
<input type="password" name="hp_api_token" value="<?= esc_attr($s['api_token']) ?>"
|
||||||
|
class="regular-text" placeholder="Bearer token (matches API_TOKEN in docker-compose)">
|
||||||
|
<p class="description">
|
||||||
|
Must match the <code>API_TOKEN</code> set in your Docker container's environment.
|
||||||
|
Leave empty only if the API is running without a token (not recommended).
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<?php submit_button('Save Settings'); ?>
|
<?php submit_button('Save Settings'); ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user