// V-25 (audit A24): the batch-import response builder echoed the submitted line // back verbatim (`raw`) and the proxy string including its userinfo // (`proxyRaw`). A failed import therefore returned the password / token / API key // the operator had just pasted, in a JSON body a browser may cache — and the // dashboard then copied it into localStorage login history. // // The response must carry structured diagnostics only: line number, error code, // kind, the (credential-free) proxy label. Nothing derived from the secret. // `Cache-Control: no-store` keeps the whole body out of any shared cache. import { afterEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { config } from '../src/config.js'; import { getAccountList, removeAccount } from '../src/auth.js'; import { handleDashboardApi } from '../src/dashboard/api.js'; import { __setLoginTransportForTests, _resetEmailFlowsForTests, _resetEmailLockoutForTests, _resetProbeHostForTests, } from '../src/dashboard/windsurf-login.js'; const originalDashboardPassword = config.dashboardPassword; const originalAllowPrivateProxyHosts = config.allowPrivateProxyHosts; const created = new Set(); const PROXY_USER = 'proxy-user'; const PROXY_PASS = 'proxy-pass-9c1f'; const ACCOUNT_PASS = 'hunter2-batch-secret'; const BATCH_TOKEN = 'auth1_batch_secret_token_value'; function fakeRes() { return { statusCode: 0, headers: {}, body: '', writeHead(status, headers) { this.statusCode = status; this.headers = headers || {}; }, end(chunk) { this.body += chunk ? String(chunk) : ''; }, json() { return this.body ? JSON.parse(this.body) : null; }, }; } function authedReq() { return { url: '/dashboard/api/batch-import', headers: { 'x-dashboard-password': 'batch-dash-secret' }, socket: { remoteAddress: '127.0.0.1' }, }; } function makeTransport(routes) { return async (url, opts, postData) => { for (const [matcher, reply] of routes) { if (url.includes(matcher)) { const data = opts?.raw ? (Buffer.isBuffer(reply.data) ? reply.data : Buffer.from(JSON.stringify(reply.data))) : reply.data; return { status: reply.status, data }; } } throw new Error(`unexpected request to ${url}`); }; } async function batchImport(body) { const res = fakeRes(); await handleDashboardApi('POST', '/batch-import', body, authedReq(), res); return { status: res.statusCode, headers: res.headers, body: res.json(), text: res.body }; } function assertNoSecrets(text) { for (const secret of [PROXY_PASS, ACCOUNT_PASS, BATCH_TOKEN]) { assert.equal(text.includes(secret), false, `response leaked ${secret}`); } } function assertNoRawFields(item) { assert.equal(Object.prototype.hasOwnProperty.call(item, 'raw'), false, 'the raw input line must not be echoed'); assert.equal(Object.prototype.hasOwnProperty.call(item, 'proxyRaw'), false, 'the raw proxy string must not be echoed'); } afterEach(() => { config.dashboardPassword = originalDashboardPassword; config.allowPrivateProxyHosts = originalAllowPrivateProxyHosts; __setLoginTransportForTests(null); _resetEmailFlowsForTests(); _resetEmailLockoutForTests(); _resetProbeHostForTests(); for (const id of created) { try { removeAccount(id); } catch { /* already gone */ } } created.clear(); }); describe('POST /batch-import response redaction', () => { it('batch import response contains no raw/proxyRaw secrets and sets Cache-Control: no-store', async () => { config.dashboardPassword = 'batch-dash-secret'; // A credentialed proxy on a private host: the SSRF gate rejects it BEFORE // any login, so this failure path is offline and deterministic — and the // baseline echoed the whole line (password included) plus proxyRaw. const text = [ `http://${PROXY_USER}:${PROXY_PASS}@127.0.0.1:8080 victim@example.test ${ACCOUNT_PASS}`, 'this-line-is-not-a-credential', ].join('\n'); const r = await batchImport({ text, autoAdd: true }); assert.equal(r.status, 200, JSON.stringify(r.body)); assertNoSecrets(r.text); assert.equal(r.headers['Cache-Control'], 'no-store', 'a credential-bearing body must not be cached'); assert.equal(r.body.results.length, 2); for (const item of r.body.results) assertNoRawFields(item); const failed = r.body.results[0]; assert.equal(failed.success, false); assert.equal(failed.email, 'victim@example.test'); assert.equal(failed.lineNumber, 1); assert.equal(typeof failed.error, 'string'); // The label is the parsed proxy, not the pasted string: host and port are // useful to the operator, the userinfo is gone. assert.equal(failed.proxy, 'http://127.0.0.1:8080'); assert.equal(typeof failed.detail, 'string'); // detail names the parsed shape and the credential-free proxy label; the // address and the password stay out of it. assert.equal(failed.detail, 'email+password · proxy http://127.0.0.1:8080'); }); it('a failed JSON token item does not echo the token or the source object', async () => { config.dashboardPassword = 'batch-dash-secret'; const r = await batchImport({ text: JSON.stringify([{ token: BATCH_TOKEN, label: 'batch-auth1' }]), autoAdd: true, }); assert.equal(r.status, 200, JSON.stringify(r.body)); assertNoSecrets(r.text); assert.equal(r.headers['Cache-Control'], 'no-store'); assert.equal(r.body.results.length, 1); const item = r.body.results[0]; assert.equal(item.success, false); assertNoRawFields(item); assert.equal(item.lineNumber, 1); assert.equal(item.importKind, 'token'); assert.equal(item.error, 'ERR_AUTH1_NOT_A_POOL_KEY'); }); it('a successful item reports the proxy without its userinfo', async () => { config.dashboardPassword = 'batch-dash-secret'; // ALLOW_PRIVATE_PROXY_HOSTS + an IP literal keeps validateProxyHost offline // (no DNS in a unit test) while exercising the SUCCESS path, which is where // the response used to carry proxyRaw verbatim. config.allowPrivateProxyHosts = true; const key = `sk-batch-redaction-${Date.now()}`; const r = await batchImport({ text: JSON.stringify([{ api_key: key, label: 'batch-redaction', proxy: `http://${PROXY_USER}:${PROXY_PASS}@10.1.2.3:8080`, }]), autoAdd: true, }); assert.equal(r.status, 200, JSON.stringify(r.body)); assertNoSecrets(r.text); assert.equal(r.headers['Cache-Control'], 'no-store'); assert.equal(r.body.successCount, 1, JSON.stringify(r.body)); const item = r.body.results[0]; const accountId = item.account?.id; assert.ok(accountId); created.add(accountId); assert.equal(item.proxy, 'http://10.1.2.3:8080', 'the proxy label must drop user:pass'); assertNoRawFields(item); // The stored account still knows its real proxy; only the RESPONSE is redacted. assert.ok(getAccountList().some(a => a.id === accountId)); }); it('the login batch route never returns a raw apiKey', async () => { config.dashboardPassword = 'batch-dash-secret'; // autoAdd:false is the one-time-export contract on /windsurf-login (a // single login for external tooling). A BATCH response has no such // contract: each row is keyed by the address the operator already owns the // password for, so a raw key in the body is only a leak. const session = 'devin-session-token$BATCHLOGINSESSION'; __setLoginTransportForTests(makeTransport([ ['windsurf.com/_devin-auth/connections', { status: 200, data: { connections: [{ id: 'email', type: 'email', enabled: true }], auth_method: { method: 'auth1', has_password: true, enterprise_sso_domain: false }, }, }], ['_devin-auth/password/login', { status: 200, data: { token: 'auth1_batch_login_throwaway' } }], ['WindsurfPostAuth', { status: 200, data: { sessionToken: session, accountId: 'account-batch' } }], ])); const res = fakeRes(); await handleDashboardApi('POST', '/windsurf-login/batch', { accounts: [{ email: 'batch-login@example.test', password: ['throwaway', 'batch'].join('-') }], autoAdd: false, }, { ...authedReq(), url: '/dashboard/api/windsurf-login/batch' }, res); assert.equal(res.statusCode, 200, res.body); const body = res.json(); const item = body.results[0]; assert.equal(item.success, true, res.body); assert.equal(item.apiKey, undefined, 'a batch response must never carry the raw session token'); assert.equal(res.body.includes(session), false, 'the session token must not appear anywhere in the body'); assert.equal(item.apiKey_masked, 'devin-se...SION'); assert.equal(res.headers['Cache-Control'], 'no-store'); }); });