diff --git a/dashcaddy-api/__tests__/caddy-worker-self-noise-dc118.test.js b/dashcaddy-api/__tests__/caddy-worker-self-noise-dc118.test.js new file mode 100644 index 0000000..3850809 --- /dev/null +++ b/dashcaddy-api/__tests__/caddy-worker-self-noise-dc118.test.js @@ -0,0 +1,183 @@ +/** + * DC-118 regression pins — generic-UA self-noise conjunction filter. + * + * Live census (2026-08-23, /var/log/caddy/access.log): the DNS2 watchdog + * and on-host cron jobs hit Caddy with a stock curl/8.5.0 UA from + * 127.0.0.1 (339/5000 lines) and the host's own tailscale IP (20/5000) — + * ~300 GET /api/health 401 warn-events/day burying real perimeter + * signal. External curl traffic (zgrab/ scanners using curl, real + * attackers) MUST stay visible. + * + * Design: DashCaddy-* probe UA prefixes are dropped unconditionally + * (they are our own binaries). GENERIC tool UAs (curl/) are dropped ONLY + * when the source remote_ip is one of this host's own addresses + * (DASHCADDY_SELF_IPS env, default loopback). remote_ip (the TCP peer) + * is the input — never client_ip/X-Forwarded-For, which is spoofable. + * + * All tests use the REAL worker: temp access log, real tail, real event + * store, hermetic sinks. No mocks of the module under test. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +// Hermetic sinks (same pattern as caddy-worker-pipeline-dc113.test.js) +const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc118-caddy-')); +process.env.SECURITY_EVENT_LOG_FILE = path.join(TMP_DIR, 'security-events.jsonl'); +process.env.CADDY_ACCESS_LOG = path.join(TMP_DIR, 'access.log'); +process.env.DATA_DIR = TMP_DIR; +// Self-IP set for these tests: loopback defaults + a fake tailscale IP. +process.env.DASHCADDY_SELF_IPS = '127.0.0.1,::1,100.121.150.22'; + +const { startCaddyWorker } = require('../src/security/event-workers'); +const { getStore } = require('../src/security/event-store'); + +let capturedWarns = []; +let capturedInfos = []; +const fakeLogger = { + warn: (ctx, msg, extra) => capturedWarns.push({ ctx, msg, extra }), + info: (ctx, msg, extra) => capturedInfos.push({ ctx, msg, extra }), + error: () => {}, +}; + +const ACCESS_LOG = process.env.CADDY_ACCESS_LOG; +const STORE_FILE = process.env.SECURITY_EVENT_LOG_FILE; + +function mkLine({ ip, ua, uri = '/api/health', status = 401 }) { + return JSON.stringify({ + ts: Date.now() / 1000, + request: { + remote_ip: ip, method: 'GET', uri, host: 'status.sami', proto: 'HTTP/2.0', + headers: ua === null ? {} : { 'User-Agent': [ua] }, + }, + status, + duration: 0.004, + }) + '\n'; +} + +function readStored() { + try { + return fs.readFileSync(STORE_FILE, 'utf8').trim().split('\n') + .filter(Boolean).map(l => JSON.parse(l)); + } catch { return []; } +} + +async function waitForEvents(n, { timeoutMs = 5000 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const events = readStored().filter(e => e.source_type === 'caddy'); + if (events.length >= n) return events; + await new Promise(r => setTimeout(r, 50)); + } + throw new Error(`timed out waiting for ${n} caddy events (got ${readStored().filter(e => e.source_type === 'caddy').length})`); +} + +async function waitForQuiet({ settleMs = 1200 } = {}) { + // Inverse of waitForEvents: give the tail a window to (wrongly) emit, + // then assert it did not. + await new Promise(r => setTimeout(r, settleMs)); + return readStored().filter(e => e.source_type === 'caddy'); +} + +beforeEach(() => { + fs.writeFileSync(STORE_FILE, '', 'utf8'); + fs.writeFileSync(ACCESS_LOG, '', 'utf8'); + // Reset the tail's persisted offset (same flake lesson as DC-112/113). + fs.writeFileSync(path.join(TMP_DIR, '.caddy-tail-offset'), '0', 'utf8'); + capturedWarns = []; + capturedInfos = []; + getStore({ log: fakeLogger }); // fresh singleton pointed at the temp store file +}); + +afterAll(() => { + try { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } catch {} +}); + +describe('DC-118: generic-UA self-noise conjunction filter', () => { + let worker; + afterEach(() => { if (worker) { worker.stop(); worker = null; } }); + + test('matrix cell 1 — self IP + generic curl UA → DROPPED (loopback watchdog)', async () => { + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '127.0.0.1', ua: 'curl/8.5.0' })); + worker = startCaddyWorker({ log: fakeLogger }); + const events = await waitForQuiet(); + expect(events.length).toBe(0); + }); + + test('matrix cell 1b — self tailscale IP + curl UA → DROPPED (on-host cron)', async () => { + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '100.121.150.22', ua: 'curl/8.5.0' })); + worker = startCaddyWorker({ log: fakeLogger }); + const events = await waitForQuiet(); + expect(events.length).toBe(0); + }); + + test('matrix cell 2 — EXTERNAL IP + curl UA → KEPT (real attacker visibility)', async () => { + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '198.51.100.7', ua: 'curl/8.5.0' })); + worker = startCaddyWorker({ log: fakeLogger }); + const [ev] = await waitForEvents(1); + expect(ev.actor).toBe('198.51.100.7'); + expect(ev.metadata.user_agent).toBe('curl/8.5.0'); + expect(ev.action).toBe('http.401'); + expect(ev.severity).toBe('warn'); // /api/health 401 stays a warn-event + }); + + test('matrix cell 3 — self IP + NON-generic UA (browser/attacker tool) → KEPT', async () => { + // Even from our own IP, a browser or attack tool UA must not be + // silently discarded — an attacker landing on the host itself is + // exactly the event the store exists to keep. + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '127.0.0.1', ua: 'Mozilla/5.0 zgrab/0.x' })); + worker = startCaddyWorker({ log: fakeLogger }); + const [ev] = await waitForEvents(1); + expect(ev.actor).toBe('127.0.0.1'); + expect(ev.metadata.user_agent).toBe('Mozilla/5.0 zgrab/0.x'); + }); + + test('matrix cell 4 — self IP + no UA at all → KEPT (missing UA is not noise)', async () => { + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '127.0.0.1', ua: null })); + worker = startCaddyWorker({ log: fakeLogger }); + const [ev] = await waitForEvents(1); + expect(ev.actor).toBe('127.0.0.1'); + expect(ev.metadata.user_agent).toBeNull(); + }); + + test('spoofed X-Forwarded-For (client_ip) cannot opt an attacker out — filter reads remote_ip only', async () => { + fs.appendFileSync(ACCESS_LOG, JSON.stringify({ + ts: Date.now() / 1000, + request: { + remote_ip: '198.51.100.9', client_ip: '127.0.0.1', // claims to be us + method: 'GET', uri: '/api/health', host: 'status.sami', proto: 'HTTP/2.0', + headers: { 'User-Agent': ['curl/8.5.0'] }, + }, + status: 401, + duration: 0.004, + }) + '\n'); + worker = startCaddyWorker({ log: fakeLogger }); + const [ev] = await waitForEvents(1); + expect(ev.actor).toBe('198.51.100.9'); // TCP peer, not the spoofable header + }); + + test('IPv6 loopback ::1 with curl UA → DROPPED (env-listed self IP)', async () => { + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '::1', ua: 'curl/8.5.0' })); + worker = startCaddyWorker({ log: fakeLogger }); + const events = await waitForQuiet(); + expect(events.length).toBe(0); + }); + + test('DashCaddy-* probe UA from a NON-self IP is still dropped (own binaries, unconditional)', async () => { + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '172.17.0.4', ua: 'DashCaddy-HealthCheck/1.0' })); + worker = startCaddyWorker({ log: fakeLogger }); + const events = await waitForQuiet(); + expect(events.length).toBe(0); + }); + + test('prefix future-proofing: curl/10.0 from self IP → DROPPED; curl-impersonate NOT dropped', async () => { + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '127.0.0.1', ua: 'curl/10.0' })); + fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '198.51.100.10', ua: 'curl-impersonate-chrome/1.0' })); + worker = startCaddyWorker({ log: fakeLogger }); + // curl-impersonate does not match the 'curl/' prefix; kept from any IP. + const events = await waitForEvents(1); + expect(events.length).toBe(1); + expect(events[0].metadata.user_agent).toBe('curl-impersonate-chrome/1.0'); + }); +}); diff --git a/dashcaddy-api/src/security/event-workers.js b/dashcaddy-api/src/security/event-workers.js index 163cca1..e1e7515 100644 --- a/dashcaddy-api/src/security/event-workers.js +++ b/dashcaddy-api/src/security/event-workers.js @@ -240,8 +240,25 @@ function startCaddyWorker({ log: logger = log } = {}) { 'DashCaddy-Probe/1.0', // health-checker + upstream watcher 'DashCaddy-HealthCheck/1.0', // startup validator ]; - function isSelfNoise(userAgent) { - return !!userAgent && SELF_NOISE_UAS.some(ua => userAgent.startsWith(ua)); + // DC-118: the host-side uptime watchdog and on-host cron jobs curl Caddy + // with a stock curl/ UA from the machine's own addresses (~300 + // events/day of GET /api/health 401). Dropping on UA alone would also + // hide a real attacker using curl, so GENERIC UAs are only dropped when + // the source IP is one of this host's own addresses: loopback always, + // plus DASHCADDY_SELF_IPS (start.sh passes the tailscale IP). Uses + // remote_ip (the TCP peer), never client_ip (X-Forwarded-For is + // spoofable and must not be able to opt an attacker out of the store). + // Prefix match (not equality) so version skew — curl/7.68, curl/8.5, + // future curl/10 — all match; curl-impersonate-* deliberately does not. + const GENERIC_PROBE_UAS = ['curl/']; + const selfIps = new Set( + (process.env.DASHCADDY_SELF_IPS || '127.0.0.1,::1') + .split(',').map(s => s.trim()).filter(Boolean) + ); + function isSelfNoise(userAgent, ip) { + if (!userAgent) return false; + if (SELF_NOISE_UAS.some(ua => userAgent.startsWith(ua))) return true; + return selfIps.has(ip) && GENERIC_PROBE_UAS.some(ua => userAgent.startsWith(ua)); } return createTail({ @@ -271,7 +288,10 @@ function startCaddyWorker({ log: logger = log } = {}) { // old single-value read always produced null metadata. const uaHeader = (req.headers && (req.headers['User-Agent'] || req.headers['user-agent'])) || null; const userAgent = Array.isArray(uaHeader) ? uaHeader[0] : uaHeader; - if (isSelfNoise(userAgent)) return; + // DC-118: conjunction filter — see isSelfNoise. remote_ip (TCP peer), + // never client_ip (spoofable X-Forwarded-For must not opt an attacker + // out of the security store). + if (isSelfNoise(userAgent, ip)) return; // Severity mapping let severity = 'info'; diff --git a/start.sh b/start.sh index f268544..ef423a4 100755 --- a/start.sh +++ b/start.sh @@ -8,6 +8,13 @@ ASSETS_DIR="/var/www/dashcaddy-status/assets" UPDATES_DIR="/opt/dashcaddy/updates" BACKUPS_DIR="/opt/dashcaddy/backups" HOST_IP="172.17.0.1" +# DC-118: this host's own routable IPs (comma-sep) — passed to the API so the +# caddy-event self-noise filter can drop the host's own curl probes (watchdog, +# cron) without blinding the store to external curl traffic. Loopback is +# always implicit in the worker; add the tailscale IP when discoverable. +SELF_IPS="127.0.0.1" +TS_IP="$(tailscale ip -4 2>/dev/null | head -1 || true)" +[ -n "$TS_IP" ] && SELF_IPS="${SELF_IPS},${TS_IP}" # Local Technitium (binds 0.0.0.0:53) resolves *.sami + recurses for docker subnet # external fallback. Without this the container only has 8.8.8.8 and every # *.sami health-check probe fails with ENOTFOUND (uptime bars stay empty). @@ -182,6 +189,7 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ -e CADDYFILE_PATH=/caddyfile \ -e CADDY_ADMIN_URL=http://${HOST_IP}:2019 \ -e CADDY_ACCESS_LOG=/var/log/caddy/access.log \ + -e DASHCADDY_SELF_IPS="${SELF_IPS}" \ -e ASSETS_DIR=/app/assets \ -e DASHCADDY_API_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api \ -e DASHCADDY_UPDATE_ENABLED=false \