[object Object]

← back to Consulting Designerwallcoverings Com

TK-22 red-team FINDING 4: fix XFF-spoof rate-limit bypass on /api/intake

b28b7419d70a4155b0e828b14694b2119b1c6dab · 2026-07-27 21:05:01 -0700 · Steve Abrams

The /api/intake rate limiter keyed off the raw, client-controllable
X-Forwarded-For header, so rotating XFF per request bypassed the 5/IP/60s
cap entirely (reproduced: 7 rotating-XFF POSTs all 200), defeating FINDING 1.
Now: app.set('trust proxy','loopback') (nginx->Node over loopback on this host,
env-overridable via TRUST_PROXY) + key the limiter off Express's trusted req.ip
instead of the raw header. Verified: rotating attacker-XFF now trips 429 after 5
while distinct real clients stay allowed; oversized still 413, boot/health OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b28b7419d70a4155b0e828b14694b2119b1c6dab
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 21:05:01 2026 -0700

    TK-22 red-team FINDING 4: fix XFF-spoof rate-limit bypass on /api/intake
    
    The /api/intake rate limiter keyed off the raw, client-controllable
    X-Forwarded-For header, so rotating XFF per request bypassed the 5/IP/60s
    cap entirely (reproduced: 7 rotating-XFF POSTs all 200), defeating FINDING 1.
    Now: app.set('trust proxy','loopback') (nginx->Node over loopback on this host,
    env-overridable via TRUST_PROXY) + key the limiter off Express's trusted req.ip
    instead of the raw header. Verified: rotating attacker-XFF now trips 429 after 5
    while distinct real clients stay allowed; oversized still 413, boot/health OK.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 15 ++++++++++++++-
 1 file changed, 14 insertions(+), 1 deletion(-)

diff --git a/server.js b/server.js
index 7ad2b02..cafd922 100644
--- a/server.js
+++ b/server.js
@@ -20,6 +20,17 @@ const { execFile } = require('node:child_process');
 try { require('dotenv').config(); } catch { /* dotenv optional */ }
 
 const app = express();
+// Red-team TK-22 FINDING 4: the /api/intake rate limiter keyed off the raw,
+// client-controllable X-Forwarded-For header, so an attacker who rotated XFF on
+// each request bypassed the 5/IP/60s cap entirely (reproduced: 7 rotating-XFF
+// POSTs all returned 200), defeating FINDING 1's protection. On this host the
+// chain is nginx -> Node over loopback (server: nginx, x-powered-by: Express),
+// with nginx appending the REAL client via $proxy_add_x_forwarded_for. Trusting
+// ONLY the loopback hop makes req.ip resolve the value nginx appended (the true
+// $remote_addr, rightmost-untrusted) and ignore any attacker-injected XFF that
+// arrived before nginx — defeating the rotate-XFF spoof. Env-overridable
+// (TRUST_PROXY) for other topologies; defaults to 'loopback'.
+app.set('trust proxy', process.env.TRUST_PROXY || 'loopback');
 app.use(express.json({ limit: '1mb' }));
 const PORT = process.env.PORT || 9702;
 const PUB = path.join(__dirname, 'public');
@@ -78,7 +89,9 @@ 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) => {
-  const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown').toString().split(',')[0].trim();
+  // 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();
   if (intakeRateLimited(ip)) {
     return res.status(429).json({ ok: false, error: 'Too many submissions — please try again in a minute.' });
   }

← fdbc2a7 TK-22 red-team FINDING 2: validate admin bucket writes + ato  ·  back to Consulting Designerwallcoverings Com  ·  TK-22 admin-UX: add created date+time chip to Low Hanging Fr 4fcf330 →