[object Object]

← back to Consulting Designerwallcoverings Com

TK-22 red-team FINDING 2: validate admin bucket writes + atomic write w/ .bak

fdbc2a781b5bce3cd15c0feab3dc96acde48eaf0 · 2026-07-27 21:01:00 -0700 · Steve Abrams

POST /api/admin/:bucket blind-wrote req.body, so one malformed/empty admin
POST (e.g. {} or a scalar) to 'client' corrupted the PUBLIC /api/client the
signin page reads. Now: require a JSON object|array, reject scalars/null (400),
cap at 512KB (413), and refuse an empty payload that would wipe existing
non-empty content (409). writeJSON is now atomic (tmp+rename) and keeps a
single-copy .bak so any accepted-but-wrong write is recoverable. .bak/.tmp
gitignored. Verified 7 cases locally; deploy Steve-gated.

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

Files touched

Diff

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

    TK-22 red-team FINDING 2: validate admin bucket writes + atomic write w/ .bak
    
    POST /api/admin/:bucket blind-wrote req.body, so one malformed/empty admin
    POST (e.g. {} or a scalar) to 'client' corrupted the PUBLIC /api/client the
    signin page reads. Now: require a JSON object|array, reject scalars/null (400),
    cap at 512KB (413), and refuse an empty payload that would wipe existing
    non-empty content (409). writeJSON is now atomic (tmp+rename) and keeps a
    single-copy .bak so any accepted-but-wrong write is recoverable. .bak/.tmp
    gitignored. Verified 7 cases locally; deploy Steve-gated.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore |  2 ++
 server.js  | 36 ++++++++++++++++++++++++++++++++++--
 2 files changed, 36 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3bebb33..1a87969 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ dist/
 build/
 .next/
 reports/
+data/*.bak
+data/*.tmp
diff --git a/server.js b/server.js
index 38278aa..7ad2b02 100644
--- a/server.js
+++ b/server.js
@@ -39,7 +39,15 @@ function gate(req, res, next) {
 
 // ---- data helpers ----------------------------------------------------------
 const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return fb; } };
-const writeJSON = (f, v) => fs.writeFileSync(path.join(DATA, f), JSON.stringify(v, null, 2));
+// Atomic write with a single-copy .bak snapshot so any bad write is recoverable.
+const writeJSON = (f, v) => {
+  const dest = path.join(DATA, f);
+  const body = JSON.stringify(v, null, 2);
+  try { if (fs.existsSync(dest)) fs.copyFileSync(dest, dest + '.bak'); } catch { /* best-effort backup */ }
+  const tmp = dest + '.tmp';
+  fs.writeFileSync(tmp, body);
+  fs.renameSync(tmp, dest); // atomic swap — never leaves a half-written file
+};
 const BUCKETS = ['client', 'socials', 'suggestions', 'competitors', 'best-times', 'content-calendar', 'ads', 'directories', 'media', 'intakes'];
 
 // ---- abuse controls for the one UNGATED write (/api/intake) -----------------
@@ -97,9 +105,33 @@ app.get('/api/admin/:bucket', gate, (req, res) => {
   if (!BUCKETS.includes(req.params.bucket)) return res.status(404).json({ error: 'unknown bucket' });
   res.json(readJSON(req.params.bucket + '.json', []));
 });
+// Red-team TK-22 FINDING 2: this gated write blind-wrote req.body with no shape
+// check, so a single malformed/empty admin POST (e.g. {"wiped":true} or {}) to
+// `client` instantly corrupted the PUBLIC /api/client the signin page reads.
+// Guard: require a JSON object|array, reject scalars/null, and refuse an empty
+// container that would wipe existing non-empty content. writeJSON now also keeps
+// a .bak so any accepted-but-wrong write is one-copy recoverable.
+const ADMIN_MAX_JSON = 512 * 1024; // 512KB per bucket write
 app.post('/api/admin/:bucket', gate, (req, res) => {
   if (!BUCKETS.includes(req.params.bucket)) return res.status(404).json({ error: 'unknown bucket' });
-  writeJSON(req.params.bucket + '.json', req.body);
+  const body = req.body;
+  if (body === null || typeof body !== 'object') {
+    return res.status(400).json({ error: 'body must be a JSON object or array' });
+  }
+  let serialized;
+  try { serialized = JSON.stringify(body); } catch { serialized = ''; }
+  if (!serialized) return res.status(400).json({ error: 'body is not serializable' });
+  if (serialized.length > ADMIN_MAX_JSON) return res.status(413).json({ error: 'payload too large' });
+  // Refuse an empty write that would silently wipe existing non-empty content.
+  const incomingEmpty = Array.isArray(body) ? body.length === 0 : Object.keys(body).length === 0;
+  if (incomingEmpty) {
+    const existing = readJSON(req.params.bucket + '.json', null);
+    const existingNonEmpty = existing && (Array.isArray(existing) ? existing.length > 0 : Object.keys(existing).length > 0);
+    if (existingNonEmpty) {
+      return res.status(409).json({ error: 'refusing to overwrite existing content with an empty payload' });
+    }
+  }
+  writeJSON(req.params.bucket + '.json', body);
   res.json({ ok: true });
 });
 

← 3cc6bd7 TK-22 red-team: harden ungated POST /api/intake (rate-limit  ·  back to Consulting Designerwallcoverings Com  ·  TK-22 red-team FINDING 4: fix XFF-spoof rate-limit bypass on b28b741 →