Ctrl K
ComunidadGuías…

test/dashboard-batch-import-redaction.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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');
  });
});