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