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