From 52b4d1c8a49d37a2369b3355a7149a37b48d0494 Mon Sep 17 00:00:00 2001 From: Jeff Dafoe Date: Wed, 10 Jun 2026 16:59:45 -0400 Subject: [PATCH] ZBBS-WORK-394: request_log takes the last X-Forwarded-For hop, not the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first XFF entry is client-controlled: nginx appends the address it actually saw after any client-supplied entries, so a scanner sending 'X-Forwarded-For: 127.0.0.1' (localhost-trust probe) was logged as 127.0.0.1 in the admin Recent API Activity panel (observed 2026-06-10, ~1,640-request wordlist burst). We sit exactly one trusted proxy deep, so the last entry is always the real peer. Display-only fix — nothing in the app makes trust decisions off this field. Co-Authored-By: Claude Fable 5 --- node/api/src/middleware/request-log.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/node/api/src/middleware/request-log.js b/node/api/src/middleware/request-log.js index 6f23f5ab..2f74e2d9 100644 --- a/node/api/src/middleware/request-log.js +++ b/node/api/src/middleware/request-log.js @@ -57,8 +57,22 @@ function requestLog(req, res, next) { return; } - // Get client IP (respect X-Forwarded-For from nginx) - const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || null; + // Get client IP from the LAST X-Forwarded-For hop. nginx appends the + // address it actually saw after any client-supplied entries + // ($proxy_add_x_forwarded_for), so earlier entries are spoofable — + // scanners send "X-Forwarded-For: 127.0.0.1" probing for localhost + // trust, and taking the first entry let that spoof into the log + // (observed 2026-06-10). We sit exactly one trusted proxy deep, so + // the last entry is always the real peer nginx connected from. + let ip = null; + const xff = req.headers['x-forwarded-for']; + if (xff) { + const hops = xff.split(','); + ip = hops[hops.length - 1].trim() || null; + } + if (!ip) { + ip = req.ip || null; + } // Capture response length: Content-Length header, or body in end(), or accumulated write() bytes let responseLength = res.getHeader('content-length') ? parseInt(res.getHeader('content-length'), 10) : null;