fix(security): caddy-event self-noise conjunction filter for host curl probes (DC-118) [glm-grade=A]
The host-side uptime watchdog and on-host cron jobs curl Caddy with a stock curl/8.5.0 UA from loopback and the host tailscale IP — ~300 GET /api/health 401 warn-events/day burying real perimeter signal (census: 339+20 of 5000 access.log lines). Dropping on UA alone would blind the store to external curl scanners, so generic tool UAs (curl/) are now dropped ONLY when the source remote_ip is one of this host's own addresses (DASHCADDY_SELF_IPS, default loopback; start.sh derives 127.0.0.1 + tailscale ip -4, empty-safe). remote_ip (TCP peer) is used, never the spoofable client_ip. DashCaddy-* probe UAs stay unconditionally dropped. 9 regression pins cover the full conjunction matrix incl. external-IP+curl KEPT and spoofed-XFF KEPT. 2858/2858 green (128 suites). Judge: glm-4.6@zai-coding-paas cold-read round 1 = A clean (0 blocking), both polish notes folded. URN urn:ump:s2sgitfepze65crtp57dpdw4gk4w7upsoi4tqcvfwahcsicyepba
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/<ver> 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';
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user