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:
2026-03-09 19:26:23 +01:00
parent 6740180981
commit bd5a67b57f
5 changed files with 105 additions and 19 deletions

View File

@@ -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 || '';