test/dashboard-auth-empty-proof.test.js
Rama master · réplica de lectura del repositorio original.
// SEC-01 regression (audit 2026-10-22): an unauthenticated empty
// `X-Dashboard-Password` header reached verifyPassword → scryptSync
// (N=2^14, ~20 ms of blocked event loop) on every request. Unlike a wrong
// NON-empty guess it also never advanced the brute-force lockout, so anyone
// who could reach the dashboard had a free CPU DoS (~46 req/s saturated the
// gateway). The fix rejects a blank proof before hashing; the real password
// path must stay byte-for-byte the same.
//
// The assertion is a real scrypt call count, not a wall-clock heuristic:
// `syncBuiltinESMExports()` makes the patched crypto.scryptSync visible to
// the named ESM import in src/runtime-config.js (verified against Node 24).
import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { syncBuiltinESMExports } from 'node:module';
import { config } from '../src/config.js';
import {
addAccountByKey, configureBindHost, removeAccount, _resetLockoutForTests,
} from '../src/auth.js';
import { handleDashboardApi } from '../src/dashboard/api.js';
import { setRuntimeApiKey, setRuntimeDashboardPassword } from '../src/runtime-config.js';
const DASHBOARD_PW = 'sec01-dashboard-pw';
const original = {
apiKey: config.apiKey,
dashboardPassword: config.dashboardPassword,
};
let scryptCalls = 0;
let originalScrypt = null;
let accountId = null;
function installScryptCounter() {
scryptCalls = 0;
originalScrypt = crypto.scryptSync;
crypto.scryptSync = (...args) => {
scryptCalls += 1;
return originalScrypt(...args);
};
syncBuiltinESMExports();
}
function restoreScrypt() {
if (!originalScrypt) return;
crypto.scryptSync = originalScrypt;
syncBuiltinESMExports();
originalScrypt = null;
}
function mkRes() {
const captured = { status: null, body: null, headers: {} };
const res = {
headersSent: false,
writeHead(status) { captured.status = status; res.headersSent = true; return res; },
end(p) { try { captured.body = JSON.parse(p); } catch { captured.body = p; } },
setHeader(k, v) { captured.headers[k] = v; },
on() {},
};
return { res, captured };
}
function mkReq(headers = {}, ip = '203.0.113.7') {
return { headers, socket: { remoteAddress: ip } };
}
beforeEach(() => {
_resetLockoutForTests();
config.apiKey = '';
config.dashboardPassword = '';
setRuntimeApiKey('');
// Hash with the real scrypt BEFORE the counter is installed, so the counter
// measures only verification work performed by the requests under test.
setRuntimeDashboardPassword(DASHBOARD_PW);
configureBindHost('0.0.0.0');
installScryptCounter();
});
afterEach(() => {
restoreScrypt();
if (accountId) { removeAccount(accountId); accountId = null; }
_resetLockoutForTests();
setRuntimeApiKey('');
setRuntimeDashboardPassword('');
config.apiKey = original.apiKey;
config.dashboardPassword = original.dashboardPassword;
configureBindHost('0.0.0.0');
});
describe('SEC-01: blank dashboard proofs never reach scrypt', () => {
it('25 requests with no header return 401 and call scrypt zero times', async () => {
for (let i = 0; i < 25; i += 1) {
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({}), res);
assert.equal(captured.status, 401, `request ${i + 1} must stay unauthorized`);
}
assert.equal(scryptCalls, 0, 'an empty header must be rejected before verifyPassword/scrypt');
});
it('whitespace-only header (string and array form) is rejected before scrypt', async () => {
for (const value of [' ', '\t\n', [' ']]) {
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': value }), res);
assert.equal(captured.status, 401);
}
assert.equal(scryptCalls, 0);
});
it('a wrong NON-empty guess still goes through verifyPassword/scrypt', async () => {
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': 'wrong-password' }), res);
assert.equal(captured.status, 401);
assert.equal(scryptCalls, 1, 'the real guessing path is unchanged');
});
it('the correct password still authenticates through the same scrypt path', async () => {
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': DASHBOARD_PW }), res);
assert.equal(captured.status, 200);
assert.equal(scryptCalls, 1);
});
it('reveal-key re-auth: blank body/header proof is refused without a second scrypt', async () => {
const account = addAccountByKey(`devin-session-token$sec01-${Date.now()}`, 'sec01');
accountId = account.id;
// Ambient dashboard auth still hashes once (checkAuth). The blank re-auth
// proof must not add another scrypt call.
for (const [body, headers] of [
[{}, { 'x-dashboard-password': DASHBOARD_PW }],
[{ password: ' ' }, { 'x-dashboard-password': DASHBOARD_PW }],
[{}, { 'x-dashboard-password': DASHBOARD_PW, 'x-dashboard-password-confirm': ' ' }],
]) {
scryptCalls = 0;
const { res, captured } = mkRes();
await handleDashboardApi('POST', `/account/${accountId}/reveal-key`, body, mkReq(headers), res);
assert.equal(captured.status, 401);
assert.equal(captured.body?.error, 'ERR_REVEAL_REAUTH_REQUIRED');
assert.equal(scryptCalls, 1, 'only the ambient checkAuth may hash; confirmReauth must not');
}
});
});