← 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 →