[object Object]

← back to Consulting Designerwallcoverings Com

TK-22 red-team: harden ungated POST /api/intake (rate-limit + size cap + FIFO cap)

3cc6bd7082d1194359aecea123717af7ee6050ae · 2026-07-27 20:56:09 -0700 · Steve Abrams

FINDING 1 (HIGH, confirmed live): the only unauthenticated write had no
rate-limit, no per-field size cap, and no stored-record cap. A single 400KB
POST grew intakes.json 2KB->402KB, and every POST rewrites the whole growing
file -> unbounded disk-fill DoS with quadratic CPU amplification on the public
dw.agentabrams.com host.

Fix (reversible, local): validate body is an object, reject payloads >16KB
(413), rate-limit to 5/IP/60s (429), and FIFO-cap retained records at 500 so
the file and every rewrite stay bounded. Verified: oversized->413, flood->429,
valid submission->200, file stays small.

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

Files touched

Diff

commit 3cc6bd7082d1194359aecea123717af7ee6050ae
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 20:56:09 2026 -0700

    TK-22 red-team: harden ungated POST /api/intake (rate-limit + size cap + FIFO cap)
    
    FINDING 1 (HIGH, confirmed live): the only unauthenticated write had no
    rate-limit, no per-field size cap, and no stored-record cap. A single 400KB
    POST grew intakes.json 2KB->402KB, and every POST rewrites the whole growing
    file -> unbounded disk-fill DoS with quadratic CPU amplification on the public
    dw.agentabrams.com host.
    
    Fix (reversible, local): validate body is an object, reject payloads >16KB
    (413), rate-limit to 5/IP/60s (429), and FIFO-cap retained records at 500 so
    the file and every rewrite stay bounded. Verified: oversized->413, flood->429,
    valid submission->200, file stays small.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 40 ++++++++++++++++++++++++++++++++++++++--
 1 file changed, 38 insertions(+), 2 deletions(-)

diff --git a/server.js b/server.js
index 4f5eab4..38278aa 100644
--- a/server.js
+++ b/server.js
@@ -42,15 +42,51 @@ const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(
 const writeJSON = (f, v) => fs.writeFileSync(path.join(DATA, f), JSON.stringify(v, null, 2));
 const BUCKETS = ['client', 'socials', 'suggestions', 'competitors', 'best-times', 'content-calendar', 'ads', 'directories', 'media', 'intakes'];
 
+// ---- abuse controls for the one UNGATED write (/api/intake) -----------------
+// Red-team TK-22 FINDING 1: unauthenticated POST /api/intake had no rate-limit,
+// no field caps, and no stored-count cap — a single 400KB POST grew the file
+// 2KB->402KB, and every POST rewrites the whole (growing) file, giving an
+// unbounded disk-fill DoS with quadratic CPU amplification on a public host.
+const INTAKE_MAX_STORED = 500;      // hard cap on retained records (FIFO drop)
+const INTAKE_MAX_JSON = 16 * 1024;  // max serialized size of one submitted intake
+const INTAKE_RL_WINDOW_MS = 60 * 1000;
+const INTAKE_RL_MAX = 5;            // max submissions per IP per window
+const intakeHits = new Map();       // ip -> [timestamps]
+function intakeRateLimited(ip) {
+  const now = Date.now();
+  const arr = (intakeHits.get(ip) || []).filter((t) => now - t < INTAKE_RL_WINDOW_MS);
+  if (arr.length >= INTAKE_RL_MAX) { intakeHits.set(ip, arr); return true; }
+  arr.push(now);
+  intakeHits.set(ip, arr);
+  if (intakeHits.size > 5000) { // bound the map itself
+    for (const [k, v] of intakeHits) { if (v.every((t) => now - t >= INTAKE_RL_WINDOW_MS)) intakeHits.delete(k); }
+  }
+  return false;
+}
+
 // ---- ungated routes --------------------------------------------------------
 app.get('/', (_req, res) => res.sendFile(path.join(PUB, 'signin.html')));
 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();
+  if (intakeRateLimited(ip)) {
+    return res.status(429).json({ ok: false, error: 'Too many submissions — please try again in a minute.' });
+  }
+  const intake = (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) ? req.body : {};
+  // Reject oversized payloads outright rather than persisting them.
+  let serialized;
+  try { serialized = JSON.stringify(intake); } catch { serialized = ''; }
+  if (!serialized || serialized.length > INTAKE_MAX_JSON) {
+    return res.status(413).json({ ok: false, error: 'Submission too large.' });
+  }
   const all = readJSON('intakes.json', []);
-  all.push({ received_at: new Date().toISOString(), source: 'form', intake: req.body || {} });
-  writeJSON('intakes.json', all);
+  const list = Array.isArray(all) ? all : [];
+  list.push({ received_at: new Date().toISOString(), source: 'form', ip, intake });
+  // FIFO cap so the file (and every rewrite) stays bounded.
+  const trimmed = list.length > INTAKE_MAX_STORED ? list.slice(list.length - INTAKE_MAX_STORED) : list;
+  writeJSON('intakes.json', trimmed);
   res.json({ ok: true, message: 'Thanks — your consulting intake was received.' });
 });
 

← 9d15ef6 TK-21: honest fleet per-site depth — rank/report by crawled  ·  back to Consulting Designerwallcoverings Com  ·  TK-22 red-team FINDING 2: validate admin bucket writes + ato fdbc2a7 →