← back to Consulting Designerwallcoverings Com
fix: rate-limiter reads X-Real-IP when req.ip is loopback (TK-22)
c35d22cdca035d86a24a61d2eaa0aaa07a5465e3 · 2026-08-25 04:16:03 -0700 · Steve Abrams
dw nginx sends X-Real-IP but not X-Forwarded-For. With trust-proxy=loopback,
req.ip resolves to 127.0.0.1 for every proxied request, collapsing the
per-IP rate bucket into a global one. Read X-Real-IP directly when
the socket address is a loopback to get the real client address.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files touched
Diff
commit c35d22cdca035d86a24a61d2eaa0aaa07a5465e3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 25 04:16:03 2026 -0700
fix: rate-limiter reads X-Real-IP when req.ip is loopback (TK-22)
dw nginx sends X-Real-IP but not X-Forwarded-For. With trust-proxy=loopback,
req.ip resolves to 127.0.0.1 for every proxied request, collapsing the
per-IP rate bucket into a global one. Read X-Real-IP directly when
the socket address is a loopback to get the real client address.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
server.js | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/server.js b/server.js
index cafd922..dbd9a43 100644
--- a/server.js
+++ b/server.js
@@ -89,9 +89,14 @@ app.get('/intake', (_req, res) => res.sendFile(path.join(PUB, 'intake.html')));
app.get('/api/health', (_req, res) => res.json({ ok: true, client: 'Designer Wallcoverings', at: new Date().toISOString() }));
app.get('/api/client', (_req, res) => res.json(readJSON('client.json', {}))); // public-safe summary for signin page
app.post('/api/intake', (req, res) => {
- // req.ip is Express's trusted client IP (derived from XFF only at the
- // configured trust-proxy hops) — not the raw, spoofable header. See FINDING 4.
- const ip = (req.ip || req.socket.remoteAddress || 'unknown').toString();
+ // Prefer X-Real-IP set by nginx (dw vhost sends this, not X-Forwarded-For).
+ // Fallback: req.ip when trust-proxy=loopback resolves a real client (e.g. local
+ // dev without nginx). Without X-Real-IP check, req.ip=127.0.0.1 for every
+ // request behind nginx, collapsing the per-IP limit to a global bucket (TK-22).
+ const rawIp = (req.ip || req.socket.remoteAddress || '').toString();
+ const ip = (rawIp === '127.0.0.1' || rawIp === '::1')
+ ? (req.headers['x-real-ip'] || rawIp || 'unknown').toString()
+ : (rawIp || 'unknown');
if (intakeRateLimited(ip)) {
return res.status(429).json({ ok: false, error: 'Too many submissions — please try again in a minute.' });
}
← 262cfd5 weekly snapshot refresh: dw-analysis + build + work-orders (
·
back to Consulting Designerwallcoverings Com
·
creds-safe fetch guard: resolve relative fetch vs credential 8ce5e3f →