← back to Costa Rica
test/admin-claims-csrf.test.js
74 lines
'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);
});