test/dashboard-auth-hardening.test.js
Rama master · réplica de lectura del repositorio original.
// AUTH-1 + XFF-1 regression coverage for src/dashboard/api.js.
//
// FINDING #1 (privileged-endpoint cluster):
// - localhost no-secret must FAIL CLOSED by default; DASHBOARD_ALLOW_NO_AUTH=1
// restores the legacy open-local behavior.
// - CORS must never answer a blanket `*`; only an allowlisted origin
// (DASHBOARD_CORS_ORIGINS) is reflected, default none.
// - reveal-key requires an explicit in-request re-auth even with ambient
// dashboard auth already passed.
// FINDING #2 (brute-force lockout XFF evasion):
// - dashboardClientIp counts TRUST_PROXY_HOPS from the RIGHT, so a spoofed
// left-most X-Forwarded-For token cannot land in a fresh lockout bucket.
import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { config } from '../src/config.js';
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
import {
configureBindHost, addAccountByKey, removeAccount,
checkLockout, failedAuthAttempt, _resetLockoutForTests,
} from '../src/auth.js';
import { handleDashboardApi } from '../src/dashboard/api.js';
import { setRuntimeApiKey, setRuntimeDashboardPassword } from '../src/runtime-config.js';
const original = {
apiKey: config.apiKey,
dashboardPassword: config.dashboardPassword,
allowNoAuth: process.env.DASHBOARD_ALLOW_NO_AUTH,
corsOrigins: process.env.DASHBOARD_CORS_ORIGINS,
trustXff: process.env.TRUST_PROXY_X_FORWARDED_FOR,
hops: process.env.TRUST_PROXY_HOPS,
};
function restoreEnv(key, val) {
if (val === undefined) delete process.env[key];
else process.env[key] = val;
}
const createdAccounts = new Set();
beforeEach(() => {
_resetLockoutForTests();
setRuntimeApiKey('');
setRuntimeDashboardPassword('');
delete process.env.DASHBOARD_ALLOW_NO_AUTH;
delete process.env.DASHBOARD_CORS_ORIGINS;
delete process.env.TRUST_PROXY_X_FORWARDED_FOR;
delete process.env.TRUST_PROXY_HOPS;
});
afterEach(() => {
for (const id of createdAccounts) removeAccount(id);
createdAccounts.clear();
_resetLockoutForTests();
setRuntimeApiKey('');
setRuntimeDashboardPassword('');
config.apiKey = original.apiKey;
config.dashboardPassword = original.dashboardPassword;
restoreEnv('DASHBOARD_ALLOW_NO_AUTH', original.allowNoAuth);
restoreEnv('DASHBOARD_CORS_ORIGINS', original.corsOrigins);
restoreEnv('TRUST_PROXY_X_FORWARDED_FOR', original.trustXff);
restoreEnv('TRUST_PROXY_HOPS', original.hops);
configureBindHost('0.0.0.0');
});
function mkRes() {
const captured = { status: null, body: null, headers: {} };
const res = {
headersSent: false,
writeHead(status, headers) {
captured.status = status;
if (headers) Object.assign(captured.headers, headers);
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() {},
write() {},
};
return { res, captured };
}
function mkReq(headers = {}, ip = '203.0.113.5') {
return { headers, socket: { remoteAddress: ip } };
}
function addTestAccount(label) {
const account = addAccountByKey(`reveal-test-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}`, label);
createdAccounts.add(account.id);
return account;
}
describe('audit #1: apiKey/no-auth dashboard fallback requires a verified-LOCAL client IP, not just a loopback bind', () => {
// Behind a reverse proxy (openresty → app on 127.0.0.1) isLocalBindHost() is
// always true — and so is the raw socket peer (the proxy connects from
// 127.0.0.1). So the fallback gates on the TRUSTED client IP (dashboardClientIp:
// honors X-Forwarded-For when TRUST_PROXY_X_FORWARDED_FOR=1), not the socket peer.
const withXff = () => { process.env.TRUST_PROXY_X_FORWARDED_FOR = '1'; };
it('loopback bind + remote socket peer + chat apiKey → 401 (no proxy trust)', async () => {
config.apiKey = 'sk-chat-shared-key';
config.dashboardPassword = '';
configureBindHost('127.0.0.1'); // app bound to loopback (proxy fronts it)
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': 'sk-chat-shared-key' }, '203.0.113.9'), res);
assert.equal(captured.status, 401, 'remote peer must NOT auth via the chat apiKey fallback');
});
it('SAME-HOST PROXY: loopback socket peer BUT remote real client (XFF) + chat apiKey → 401', async () => {
// The reviewer-caught hole: behind a same-host proxy the socket peer is
// 127.0.0.1 for everyone. With XFF trust on, dashboardClientIp resolves the
// real client from X-Forwarded-For — a remote client must be rejected even
// though the socket peer is loopback.
withXff();
config.apiKey = 'sk-chat-shared-key';
config.dashboardPassword = '';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
// socket peer = 127.0.0.1 (the proxy), XFF carries the real remote client
const req = { headers: { 'x-dashboard-password': 'sk-chat-shared-key', 'x-forwarded-for': '203.0.113.9' }, socket: { remoteAddress: '127.0.0.1' } };
await handleDashboardApi('GET', '/config', {}, req, res);
assert.equal(captured.status, 401, 'proxied remote client (via XFF) must NOT auth via the apiKey fallback despite a loopback socket peer');
});
it('SAME-HOST PROXY: loopback socket peer + loopback real client (XFF) + chat apiKey + opt-in → 200', async () => {
// A genuinely local client whose real IP (via XFF) is loopback still works
// — but only when the apiKey-as-password convenience is explicitly opted in
// (H2: default OFF now).
withXff();
process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD = '1';
config.apiKey = 'sk-chat-shared-key';
config.dashboardPassword = '';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
const req = { headers: { 'x-dashboard-password': 'sk-chat-shared-key', 'x-forwarded-for': '127.0.0.1' }, socket: { remoteAddress: '127.0.0.1' } };
await handleDashboardApi('GET', '/config', {}, req, res);
assert.equal(captured.status, 200, 'verified-local client (XFF loopback) keeps the opt-in convenience');
delete process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD;
});
it('loopback bind + loopback peer + chat apiKey as password + opt-in → 200 (genuine local convenience)', async () => {
process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD = '1';
config.apiKey = 'sk-chat-shared-key';
config.dashboardPassword = '';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': 'sk-chat-shared-key' }, '127.0.0.1'), res);
assert.equal(captured.status, 200, 'loopback peer with the correct apiKey is accepted when opted in');
delete process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD;
});
it('H2: loopback peer + chat apiKey as password but NO opt-in → 401 (default hardened)', async () => {
delete process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD;
config.apiKey = 'sk-chat-shared-key';
config.dashboardPassword = '';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': 'sk-chat-shared-key' }, '127.0.0.1'), res);
assert.equal(captured.status, 401, 'apiKey-as-password must be denied by default without opt-in');
});
it('a configured DASHBOARD_PASSWORD is unaffected (remote peer with correct password still works)', async () => {
config.apiKey = 'sk-chat-shared-key';
config.dashboardPassword = 'real-admin-pw';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': 'real-admin-pw' }, '203.0.113.9'), res);
assert.equal(captured.status, 200, 'explicit dashboard password auths regardless of peer');
});
// The two tests above stop at the 401 that the *default-OFF opt-in* already
// produces, so they stay green if the `isLoopbackAddress(clientIp)` half of the
// predicate is deleted. These two arm the opt-in, which leaves the loopback
// check as the only thing that can refuse the caller.
it('G-6: SAME-HOST PROXY with the apiKey convenience ARMED → a remote client is still refused', async () => {
withXff();
process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD = '1'; // the convenience is explicitly on
config.apiKey = 'sk-chat-shared-key';
config.dashboardPassword = '';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
const req = {
headers: { 'x-dashboard-password': 'sk-chat-shared-key', 'x-forwarded-for': '203.0.113.9' },
socket: { remoteAddress: '127.0.0.1' },
};
await handleDashboardApi('GET', '/config', {}, req, res);
assert.equal(captured.status, 401, 'with the opt-in armed, only the verified-local check can refuse this caller');
delete process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD;
});
it('G-6: SAME-HOST PROXY with DASHBOARD_ALLOW_NO_AUTH=1 → remote refused, verified-local still allowed', async () => {
withXff();
process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; // the open-local convenience is explicitly on
config.apiKey = '';
config.dashboardPassword = '';
configureBindHost('127.0.0.1');
const remote = mkRes();
await handleDashboardApi('GET', '/config', {}, {
headers: { 'x-forwarded-for': '203.0.113.9' },
socket: { remoteAddress: '127.0.0.1' },
}, remote.res);
assert.equal(remote.captured.status, 401, 'an open-local opt-in must not become an open-remote opt-in');
const local = mkRes();
await handleDashboardApi('GET', '/config', {}, {
headers: { 'x-forwarded-for': '127.0.0.1' },
socket: { remoteAddress: '127.0.0.1' },
}, local.res);
assert.equal(local.captured.status, 200, 'control: the convenience still serves a verified-local client');
delete process.env.DASHBOARD_ALLOW_NO_AUTH;
});
});
describe('AUTH-1: localhost no-secret fail-closed by default', () => {
it('localhost + no secret + no opt-in → 401 (fail closed)', async () => {
config.apiKey = '';
config.dashboardPassword = '';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({}, '127.0.0.1'), res);
assert.equal(captured.status, 401, 'default localhost-no-secret must fail closed');
});
it('localhost + no secret + DASHBOARD_ALLOW_NO_AUTH=1 → 200 (legacy open-local opt-in)', async () => {
config.apiKey = '';
config.dashboardPassword = '';
process.env.DASHBOARD_ALLOW_NO_AUTH = '1';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({}, '127.0.0.1'), res);
assert.equal(captured.status, 200, 'opt-in must restore open-local behavior');
});
it('/auth probe reports required:true when open-local not opted in', async () => {
config.apiKey = '';
config.dashboardPassword = '';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/auth', {}, mkReq({}, '127.0.0.1'), res);
assert.equal(captured.status, 200);
assert.equal(captured.body.required, true, 'probe must not advertise an open dashboard when fail-closed');
});
it('/auth probe reports required:false only with the opt-in', async () => {
config.apiKey = '';
config.dashboardPassword = '';
process.env.DASHBOARD_ALLOW_NO_AUTH = '1';
configureBindHost('127.0.0.1');
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/auth', {}, mkReq({}, '127.0.0.1'), res);
assert.equal(captured.body.required, false);
});
});
describe('AUTH-1: CORS is not a blanket wildcard', () => {
it('default (no DASHBOARD_CORS_ORIGINS) → no Access-Control-Allow-Origin header', async () => {
config.dashboardPassword = 'admin-pw';
configureBindHost('0.0.0.0');
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': 'admin-pw', origin: 'https://evil.example' }), res);
assert.equal(captured.status, 200);
assert.equal(captured.headers['Access-Control-Allow-Origin'], undefined, 'no ACAO by default');
});
it('OPTIONS preflight from a disallowed origin gets no ACAO', async () => {
process.env.DASHBOARD_CORS_ORIGINS = 'https://ok.example';
const { res, captured } = mkRes();
await handleDashboardApi('OPTIONS', '/config', {}, mkReq({ origin: 'https://evil.example' }), res);
assert.equal(captured.status, 204);
assert.equal(captured.headers['Access-Control-Allow-Origin'], undefined);
});
it('OPTIONS preflight from an allowlisted origin echoes it (not `*`)', async () => {
process.env.DASHBOARD_CORS_ORIGINS = 'https://ok.example';
const { res, captured } = mkRes();
await handleDashboardApi('OPTIONS', '/config', {}, mkReq({ origin: 'https://ok.example' }), res);
assert.equal(captured.status, 204);
assert.equal(captured.headers['Access-Control-Allow-Origin'], 'https://ok.example');
assert.notEqual(captured.headers['Access-Control-Allow-Origin'], '*');
});
// #9: the dashboard OPTIONS handler above is only REACHED if the global
// OPTIONS short-circuit in server.js excludes /dashboard/api/. Without the
// exclusion, every dashboard preflight is answered with a blanket ACAO:* by
// server.js:route and the allowlist handler is dead code. Pin the routing
// invariant at the source level (route() is not exported / no full-route
// harness — same source-assertion convention as http-cache-control.test.js).
it('server.js global OPTIONS short-circuit excludes /dashboard/api/ so the allowlist handler is reachable', () => {
const src = readFileSync(join(REPO_ROOT, 'src/server.js'), 'utf-8');
assert.match(
src,
/if \(method === 'OPTIONS' && !path\.startsWith\('\/dashboard\/api\/'\)\)/,
'global OPTIONS handler must exclude /dashboard/api/ (else dashboard preflight is a blanket `*`)',
);
});
it('allowlisted origin is echoed (not `*`) with Vary: Origin', async () => {
process.env.DASHBOARD_CORS_ORIGINS = 'https://ok.example, https://also.example';
config.dashboardPassword = 'admin-pw';
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {}, mkReq({ 'x-dashboard-password': 'admin-pw', origin: 'https://ok.example' }), res);
assert.equal(captured.headers['Access-Control-Allow-Origin'], 'https://ok.example');
assert.notEqual(captured.headers['Access-Control-Allow-Origin'], '*');
assert.equal(captured.headers['Vary'], 'Origin');
});
});
describe('AUTH-1: reveal-key requires explicit re-auth', () => {
it('ambient auth alone (no password in body) → 401', async () => {
config.dashboardPassword = 'admin-pw';
configureBindHost('0.0.0.0');
const acct = addTestAccount('noreauth');
const { res, captured } = mkRes();
await handleDashboardApi('POST', `/account/${acct.id}/reveal-key`, {}, mkReq({ 'x-dashboard-password': 'admin-pw' }), res);
assert.equal(captured.status, 401, 'reveal-key must demand in-request re-auth even when ambient auth passed');
assert.equal(captured.body.error, 'ERR_REVEAL_REAUTH_REQUIRED');
});
it('correct re-auth password in body → 200 with cleartext key', async () => {
config.dashboardPassword = 'admin-pw';
configureBindHost('0.0.0.0');
const acct = addTestAccount('reauth-ok');
const { res, captured } = mkRes();
await handleDashboardApi('POST', `/account/${acct.id}/reveal-key`, { password: 'admin-pw' }, mkReq({ 'x-dashboard-password': 'admin-pw' }), res);
assert.equal(captured.status, 200);
assert.equal(captured.body.success, true);
assert.equal(typeof captured.body.apiKey, 'string');
assert.ok(captured.body.apiKey.length > 0);
});
it('wrong re-auth password → 401', async () => {
config.dashboardPassword = 'admin-pw';
configureBindHost('0.0.0.0');
const acct = addTestAccount('reauth-wrong');
const { res, captured } = mkRes();
await handleDashboardApi('POST', `/account/${acct.id}/reveal-key`, { password: 'not-the-pw' }, mkReq({ 'x-dashboard-password': 'admin-pw' }), res);
assert.equal(captured.status, 401);
});
it('re-auth accepted via X-Dashboard-Password-Confirm header', async () => {
config.dashboardPassword = 'admin-pw';
configureBindHost('0.0.0.0');
const acct = addTestAccount('reauth-header');
const { res, captured } = mkRes();
await handleDashboardApi('POST', `/account/${acct.id}/reveal-key`, {},
mkReq({ 'x-dashboard-password': 'admin-pw', 'x-dashboard-password-confirm': 'admin-pw' }), res);
assert.equal(captured.status, 200);
assert.equal(captured.body.success, true);
});
});
describe('XFF-1: dashboard lockout counts client IP from the right', () => {
it('spoofed left-most XFF token cannot dodge the 5-strike ban (single trusted hop)', async () => {
config.dashboardPassword = 'admin-pw';
configureBindHost('0.0.0.0');
process.env.TRUST_PROXY_X_FORWARDED_FOR = '1';
// TRUST_PROXY_HOPS defaults to 1. A single trusted proxy appends the peer
// it received the connection from (the real client) to the RIGHT of the
// header, so the trustworthy client IP is the last token. The attacker
// rotates the LEFT-most token every request to try to land in a fresh
// lockout bucket; the right-counted resolution defeats it.
const realClient = '198.51.100.9';
for (let i = 0; i < 5; i++) {
const { res, captured } = mkRes();
const spoof = `9.9.9.${i}`; // fresh left-most token each request
await handleDashboardApi('GET', '/config', {},
mkReq({ 'x-dashboard-password': 'wrong', 'x-forwarded-for': `${spoof}, ${realClient}` }, '10.0.0.1'), res);
assert.equal(captured.status, 401, `attempt ${i + 1} expected 401`);
}
// 6th attempt — despite a brand-new left-most token — must be banned,
// because all six resolved to the same right-counted real client IP.
const { res, captured } = mkRes();
await handleDashboardApi('GET', '/config', {},
mkReq({ 'x-dashboard-password': 'wrong', 'x-forwarded-for': `9.9.9.99, ${realClient}` }, '10.0.0.1'), res);
assert.equal(captured.status, 429, 'spoofing the left-most XFF token must NOT reset the lockout bucket');
});
it('the banned bucket is the right-counted client IP, not the spoofed left token', async () => {
config.dashboardPassword = 'admin-pw';
configureBindHost('0.0.0.0');
process.env.TRUST_PROXY_X_FORWARDED_FOR = '1';
const realClient = '198.51.100.20';
for (let i = 0; i < 5; i++) {
const { res } = mkRes();
await handleDashboardApi('GET', '/config', {},
mkReq({ 'x-dashboard-password': 'wrong', 'x-forwarded-for': `1.2.3.${i}, ${realClient}` }, '10.0.0.1'), res);
}
assert.equal(checkLockout(realClient).blocked, true, 'lockout must key on the real right-counted client IP');
assert.equal(checkLockout('1.2.3.0').blocked, false, 'spoofed left-most token must never be the banned key');
});
it('honours TRUST_PROXY_HOPS=2 (two trusted proxies) — client is third-from-... i.e. len-2', async () => {
config.dashboardPassword = 'admin-pw';
configureBindHost('0.0.0.0');
process.env.TRUST_PROXY_X_FORWARDED_FOR = '1';
process.env.TRUST_PROXY_HOPS = '2';
// Two trusted proxies append two entries on the right; real client is the
// token just before them: parts[len-2]. Header: spoof, realClient, proxyB.
const realClient = '198.51.100.30';
for (let i = 0; i < 5; i++) {
const { res } = mkRes();
await handleDashboardApi('GET', '/config', {},
mkReq({ 'x-dashboard-password': 'wrong', 'x-forwarded-for': `7.7.7.${i}, ${realClient}, 10.0.0.2` }, '10.0.0.1'), res);
}
assert.equal(checkLockout(realClient).blocked, true, 'with 2 hops the client IP is parts[len-2]');
assert.equal(checkLockout('7.7.7.0').blocked, false);
});
});