← back to Costa Rica
costa-rica: block CSRF on the admin claim-approval mutation (cycle 28) — TK-10346
75b27d7677863b5adce7a67c3ebdbf7728361059 · 2026-09-24 06:25:53 -0700 · Steve
Cold Cody audit of the admin surface. Injection is clean, PII selects are scoped,
and the basic-auth mount ordering correctly covers /api/admin TODAY. But
POST /api/admin/claims/:placeId/:hostId (approve/reject a host claim) was
CSRF-able: HTTP basic-auth creds are auto-attached cross-site by the browser, and
express.urlencoded is mounted globally, so an attacker page's auto-submitting
<form> (application/x-www-form-urlencoded — a "simple" request, no CORS preflight)
could drive the mutation against a logged-in admin. CORS doesn't help (it's scoped
to /api/app and doesn't apply to a simple form POST anyway).
Fix: the mutation now requires application/json. A cross-site simple form cannot
set that content-type without triggering a CORS preflight, which /api/admin does
not answer -> the forged POST is rejected 415 before any DB write. The real admin
UI already sends application/json (public/admin.html), so it's transparent.
Verified req.is() matches with/without charset and that application/json is a
non-simple content-type (attacker can't send it cross-site to a CORS-less route).
test/admin-claims-csrf.test.js: a form-urlencoded and a text/plain POST are both
415 with NO UPDATE; the JSON path still approves; a bad decision is still 400.
Suite 226 -> 230. (Minimal content-type guard on a surface just Cody-audited this
cycle + a proving test + reasoned bypass analysis; committed without a re-gate,
proportionate to task weight.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
Files touched
M routes/admin.jsA test/admin-claims-csrf.test.js
Diff
commit 75b27d7677863b5adce7a67c3ebdbf7728361059
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 24 06:25:53 2026 -0700
costa-rica: block CSRF on the admin claim-approval mutation (cycle 28) — TK-10346
Cold Cody audit of the admin surface. Injection is clean, PII selects are scoped,
and the basic-auth mount ordering correctly covers /api/admin TODAY. But
POST /api/admin/claims/:placeId/:hostId (approve/reject a host claim) was
CSRF-able: HTTP basic-auth creds are auto-attached cross-site by the browser, and
express.urlencoded is mounted globally, so an attacker page's auto-submitting
<form> (application/x-www-form-urlencoded — a "simple" request, no CORS preflight)
could drive the mutation against a logged-in admin. CORS doesn't help (it's scoped
to /api/app and doesn't apply to a simple form POST anyway).
Fix: the mutation now requires application/json. A cross-site simple form cannot
set that content-type without triggering a CORS preflight, which /api/admin does
not answer -> the forged POST is rejected 415 before any DB write. The real admin
UI already sends application/json (public/admin.html), so it's transparent.
Verified req.is() matches with/without charset and that application/json is a
non-simple content-type (attacker can't send it cross-site to a CORS-less route).
test/admin-claims-csrf.test.js: a form-urlencoded and a text/plain POST are both
415 with NO UPDATE; the JSON path still approves; a bad decision is still 400.
Suite 226 -> 230. (Minimal content-type guard on a surface just Cody-audited this
cycle + a proving test + reasoned bypass analysis; committed without a re-gate,
proportionate to task weight.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
routes/admin.js | 8 +++++
test/admin-claims-csrf.test.js | 73 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 81 insertions(+)
diff --git a/routes/admin.js b/routes/admin.js
index 7e1f2a7..6cc424b 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -34,6 +34,14 @@ router.get('/claims', async (req, res) => {
});
router.post('/claims/:placeId/:hostId', async (req, res) => {
+ // CSRF guard (Cody admin audit, cycle 28): basic-auth creds are auto-attached by
+ // the browser cross-site, and express.urlencoded is mounted globally, so a
+ // cross-site <form> POST (application/x-www-form-urlencoded — a "simple" request,
+ // no CORS preflight) could drive this state mutation against a logged-in admin.
+ // Require application/json: a cross-site simple form cannot set it without
+ // triggering a preflight that admin CORS does not allow, so the forged POST is
+ // rejected; the real admin UI already sends JSON (public/admin.html).
+ if (!req.is('application/json')) return res.status(415).json({ ok: false, error: 'admin mutations require application/json' });
const { decision } = req.body || {};
if (!['approved', 'rejected'].includes(decision)) return res.status(400).json({ ok: false, error: 'bad decision' });
const { rows } = await pool.query(
diff --git a/test/admin-claims-csrf.test.js b/test/admin-claims-csrf.test.js
new file mode 100644
index 0000000..6d4c099
--- /dev/null
+++ b/test/admin-claims-csrf.test.js
@@ -0,0 +1,73 @@
+'use strict';
+// CSRF hardening for the admin claim-decision mutation (Cody admin audit, cycle 28).
+// POST /api/admin/claims/:placeId/:hostId approves/rejects a host claim. Basic-auth
+// creds are auto-attached cross-site + express.urlencoded is global, so a cross-site
+// <form> POST could forge this mutation against a logged-in admin. The route now
+// requires application/json (which a simple cross-site form cannot send without a
+// blocked CORS preflight). These prove: a form-encoded / text POST is rejected 415
+// BEFORE any DB write, and the real JSON path still works.
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const express = require('express');
+
+const db = require('../lib/db');
+const adminRouter = require('../routes/admin');
+
+let responses = [];
+let calls = [];
+const origQuery = db.pool.query;
+let server, base;
+before(async () => {
+ db.pool.query = async (sql, args) => { calls.push({ sql, args }); return responses.length ? responses.shift() : { rows: [], rowCount: 0 }; };
+ const app = express();
+ app.use(express.json()); // mirror server.js global parsers
+ app.use(express.urlencoded({ extended: true }));
+ app.use('/api/admin', adminRouter); // no basic-auth here — we're testing the route's own CSRF guard
+ await new Promise(r => { server = app.listen(0, r); });
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => { db.pool.query = origQuery; server && server.close(); });
+
+function reset(resp) { responses = resp.slice(); calls = []; }
+const didUpdate = () => calls.some(c => /UPDATE place_hosts/i.test(c.sql));
+
+function post(path, body, contentType) {
+ return new Promise((resolve, reject) => {
+ const r = http.request(base + path, { method: 'POST', headers: { 'content-type': contentType, 'content-length': Buffer.byteLength(body) } },
+ res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
+ r.on('error', reject); r.end(body);
+ });
+}
+
+test('CSRF: a cross-site form-urlencoded POST is rejected 415 with NO DB write', async () => {
+ reset([{ rows: [{ place_id: 5, host_id: 9, claim_status: 'approved' }] }]);
+ const r = await post('/api/admin/claims/5/9', 'decision=approved', 'application/x-www-form-urlencoded');
+ assert.equal(r.status, 415);
+ assert.match(r.json.error, /application\/json/);
+ assert.equal(didUpdate(), false, 'the forged form POST must not reach the UPDATE');
+});
+
+test('CSRF: a text/plain POST (also a "simple" request) is rejected 415 with NO DB write', async () => {
+ reset([{ rows: [{ place_id: 5, host_id: 9 }] }]);
+ const r = await post('/api/admin/claims/5/9', JSON.stringify({ decision: 'approved' }), 'text/plain');
+ assert.equal(r.status, 415);
+ assert.equal(didUpdate(), false);
+});
+
+test('the real admin UI path (application/json) still approves a claim', async () => {
+ reset([{ rows: [{ place_id: 5, host_id: 9, claim_status: 'approved' }] }]);
+ const r = await post('/api/admin/claims/5/9', JSON.stringify({ decision: 'approved' }), 'application/json');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.ok, true);
+ assert.ok(didUpdate(), 'a genuine JSON request performs the UPDATE');
+});
+
+test('a JSON POST with a bad decision is still 400 (validation intact, no DB write)', async () => {
+ reset([]);
+ const r = await post('/api/admin/claims/5/9', JSON.stringify({ decision: 'hacked' }), 'application/json');
+ assert.equal(r.status, 400);
+ assert.match(r.json.error, /bad decision/);
+ assert.equal(didUpdate(), false);
+});
← edda387 cycle 27 docs: YOLO_NOTES + GO-LIVE — SIWA hardening + 2 def
·
back to Costa Rica
·
cycle 28 docs: YOLO_NOTES ledger — admin CSRF fix + gate-cou 0b0f3fc →