← back to Wallco Ai
SECURITY: admin-gate isLocalhost no longer trusts Host header — gate on loopback TCP peer + absence of proxy headers (fixes 'curl -H Host:localhost' admin bypass; claude-codex finding)
624d5429ccba28fcd649538038c7525f2b8a449b · 2026-06-01 14:02:50 -0700 · Steve Abrams
Files touched
Diff
commit 624d5429ccba28fcd649538038c7525f2b8a449b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 1 14:02:50 2026 -0700
SECURITY: admin-gate isLocalhost no longer trusts Host header — gate on loopback TCP peer + absence of proxy headers (fixes 'curl -H Host:localhost' admin bypass; claude-codex finding)
---
src/admin-gate.js | 29 ++++++++++++++++++++++++++++-
1 file changed, 28 insertions(+), 1 deletion(-)
diff --git a/src/admin-gate.js b/src/admin-gate.js
index 66b03bd..b04cd30 100644
--- a/src/admin-gate.js
+++ b/src/admin-gate.js
@@ -21,9 +21,36 @@
const jwt = require('jsonwebtoken');
const LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
+const LOOPBACK_IPS = new Set(['127.0.0.1', '::1']);
+// The raw TCP peer address — NOT req.hostname (the Host header) and NOT req.ip
+// (which honors X-Forwarded-For when trust-proxy is on). Both of those are
+// client-controllable; the socket peer is not.
+function peerIp(req) {
+ const raw = (req.socket && req.socket.remoteAddress)
+ || (req.connection && req.connection.remoteAddress)
+ || '';
+ return String(raw).replace(/^::ffff:/, ''); // unwrap IPv4-mapped IPv6
+}
+
+// Loopback admin is granted ONLY for a genuine on-box request:
+// (1) the TCP peer is a loopback address, AND
+// (2) the request did NOT arrive through the nginx reverse proxy.
+//
+// SECURITY (2026-06-01): the old check was `LOCAL_HOSTS.has(req.hostname)`, i.e.
+// it trusted the Host header — so `curl -H 'Host: localhost' https://wallco.ai/admin/...`
+// granted full admin on every gated route. Fixed per claude-codex debate finding.
+//
+// Behind nginx, EVERY proxied (public) request also arrives with
+// remoteAddress=127.0.0.1, so the peer IP alone is insufficient. nginx always
+// sets X-Forwarded-For / X-Real-IP on proxied traffic, and the app port
+// (9905/9878) is firewalled off from the public internet — so the only way to
+// reach the socket as loopback WITHOUT those headers is a process on the box.
function isLocalhost(req) {
- return LOCAL_HOSTS.has(req.hostname);
+ if (!LOOPBACK_IPS.has(peerIp(req))) return false;
+ const h = req.headers || {};
+ const proxied = h['x-forwarded-for'] || h['x-real-ip'] || h['x-forwarded-host'] || h['cf-connecting-ip'];
+ return !proxied;
}
// Manual cookie reader — cookie-parser is NOT in deps, so req.cookies is
← 66f0082 Redirect /api/design/{null,undefined,NaN} home too (belt-and
·
back to Wallco Ai
·
deploy: orphaned-entrypoint guard — warn if a pm2 process po c76ad29 →