← 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);
});