fix(security): caddy-event self-noise conjunction filter for host curl probes (DC-118) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

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:
Hermes
2026-08-23 15:53:58 -07:00
parent 2dce6dca5e
commit 1744d1c86e
3 changed files with 214 additions and 3 deletions
+23 -3
View File
@@ -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';