Ctrl K
ComunidadGuías…

test/dashboard-host-boundary.test.js

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

Archivo original ↗Preguntar a KiBot
// D1 regression: `/auth/*` is an operator surface with the same DNS-rebinding
// exposure as `/dashboard/api/*`, and a client-supplied X-Forwarded-Host must
// not bypass DASHBOARD_ALLOWED_HOSTS when proxy trust is enabled.
//
// Before the fix (audit2/Server.md S01/S02):
//   - open-local `GET /auth/accounts` and account-adding `POST /auth/login`
//     answered 200 for `Host: attacker.example`, because verifyAdminRequest
//     authenticates from the loopback *socket* (a rebinding page is loopback);
//   - `X-Forwarded-Host: attacker.example` short-circuited the host allowlist
//     whenever TRUST_PROXY_X_FORWARDED_FOR=1, so the request fell through to
//     auth instead of being rejected at the host gate.
//
// This file drives a real `startServer()` over raw http.request so Host,
// Origin, X-Forwarded-For and X-Forwarded-Host are verbatim on the wire.
// Account rows are asserted against the real pool, so "403 with zero account
// delta" is measured, not inferred from the response shape. Proxy arms set a
// dashboard password and send it, so the Host check — not auth — is the only
// discriminator (the audit's recommended harness).

import { after, afterEach, before, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { config } from '../src/config.js';
import {
  configureBindHost, getAccountCount, getAccountList, removeAccount, _resetLockoutForTests,
} from '../src/auth.js';
import { _resetRuntimeConfigForTests } from '../src/runtime-config.js';
import { startServer } from '../src/server.js';

const DASHBOARD_PASSWORD = 'host-boundary-test-password';
const PROXY_HOST = 'dashboard.internal.example';

const saved = {
  host: config.host,
  port: config.port,
  apiKey: config.apiKey,
  dashboardPassword: config.dashboardPassword,
};
const savedEnv = {};
for (const key of [
  'WINDSURFAPI_ALLOW_UNAUTHENTICATED',
  'DASHBOARD_ALLOW_NO_AUTH',
  'DASHBOARD_ALLOWED_HOSTS',
  'DASHBOARD_CORS_ORIGINS',
  'TRUST_PROXY_X_FORWARDED_FOR',
]) {
  savedEnv[key] = process.env[key];
}

function restoreEnv(key) {
  const value = savedEnv[key];
  if (value === undefined) delete process.env[key];
  else process.env[key] = value;
}

function resetHostEnv() {
  delete process.env.TRUST_PROXY_X_FORWARDED_FOR;
  delete process.env.DASHBOARD_ALLOWED_HOSTS;
  delete process.env.DASHBOARD_CORS_ORIGINS;
  process.env.WINDSURFAPI_ALLOW_UNAUTHENTICATED = '1';
  process.env.DASHBOARD_ALLOW_NO_AUTH = '1';
}

let server = null;
let port = 0;

before(async () => {
  _resetRuntimeConfigForTests();
  _resetLockoutForTests();
  config.host = '127.0.0.1';
  config.port = 0;
  config.apiKey = '';
  config.dashboardPassword = '';
  resetHostEnv();
  server = startServer();
  await new Promise((resolve, reject) => {
    server.once('listening', resolve);
    server.once('error', reject);
  });
  port = server.address().port;
});

after(async () => {
  if (server) await new Promise(resolve => server.close(resolve));
  config.host = saved.host;
  config.port = saved.port;
  config.apiKey = saved.apiKey;
  config.dashboardPassword = saved.dashboardPassword;
  configureBindHost('0.0.0.0');
  for (const key of Object.keys(savedEnv)) restoreEnv(key);
});

beforeEach(() => {
  _resetRuntimeConfigForTests();
  _resetLockoutForTests();
  resetHostEnv();
  config.apiKey = '';
  config.dashboardPassword = '';
});

afterEach(() => {
  for (const account of getAccountList()) {
    if (String(account?.email || '').includes('host-boundary')) removeAccount(account.id);
  }
});

function request(method, path, { headers = {}, body = null } = {}) {
  return new Promise((resolve, reject) => {
    const payload = body == null
      ? null
      : (typeof body === 'string' ? body : JSON.stringify(body));
    const req = http.request({
      host: '127.0.0.1',
      port,
      path,
      method,
      headers: {
        ...(payload == null ? {} : { 'Content-Length': Buffer.byteLength(payload) }),
        ...headers,
      },
    }, (res) => {
      let raw = '';
      res.on('data', chunk => { raw += chunk; });
      res.on('end', () => {
        let parsed = null;
        try { parsed = raw ? JSON.parse(raw) : null; } catch { parsed = raw; }
        resolve({ status: res.statusCode, body: parsed, raw, headers: res.headers });
      });
    });
    req.on('error', reject);
    if (payload != null) req.write(payload);
    req.end();
  });
}

// Proxy arms use a real operator secret so the Host gate, not auth, decides.
function withPassword() {
  config.dashboardPassword = DASHBOARD_PASSWORD;
  return { 'x-dashboard-password': DASHBOARD_PASSWORD };
}

describe('D1: /auth/* Host boundary (open-local)', () => {
  it('rejects a rebinding Host on GET /auth/accounts without listing accounts', async () => {
    const before = getAccountCount().total;
    const r = await request('GET', '/auth/accounts', { headers: { Host: 'attacker.example' } });
    assert.equal(r.status, 403, r.raw);
    assert.equal(r.body?.error, 'ERR_DASHBOARD_HOST_REJECTED');
    assert.equal(r.body?.accounts, undefined, 'a rejected request must not leak the pool');
    assert.equal(getAccountCount().total, before);
  });

  it('accepts the loopback Host on GET /auth/accounts', async () => {
    const r = await request('GET', '/auth/accounts', { headers: { Host: `127.0.0.1:${port}` } });
    assert.equal(r.status, 200, r.raw);
    assert.ok(Array.isArray(r.body?.accounts));
  });

  it('rejects an account-adding POST /auth/login with zero account delta', async () => {
    const before = getAccountCount().total;
    const r = await request('POST', '/auth/login', {
      headers: {
        Host: 'attacker.example',
        Origin: 'http://attacker.example',
        'Content-Type': 'text/plain',
      },
      body: { token: 'devin-session-token$host-boundary-probe' },
    });
    assert.equal(r.status, 403, r.raw);
    assert.equal(r.body?.error, 'ERR_DASHBOARD_HOST_REJECTED');
    assert.equal(getAccountCount().total, before, 'a rejected login must not add an account');
    assert.equal(
      getAccountList().some(account => String(account?.email || '').includes('host-boundary-probe')),
      false,
    );
  });

  it('still adds the account from the loopback Host (positive control)', async () => {
    const before = getAccountCount().total;
    const r = await request('POST', '/auth/login', {
      headers: { Host: `127.0.0.1:${port}`, 'Content-Type': 'application/json' },
      body: { token: 'devin-session-token$host-boundary-control' },
    });
    assert.equal(r.status, 200, r.raw);
    assert.equal(r.body?.success, true);
    assert.equal(getAccountCount().total, before + 1);
  });
});

describe('D1: trusted-proxy X-Forwarded-Host cannot bypass DASHBOARD_ALLOWED_HOSTS', () => {
  it('rejects an unallowlisted client-supplied X-Forwarded-Host on /dashboard/api/*', async () => {
    process.env.TRUST_PROXY_X_FORWARDED_FOR = '1';
    process.env.DASHBOARD_ALLOWED_HOSTS = PROXY_HOST;
    const auth = withPassword();
    const r = await request('PUT', '/dashboard/api/settings/prefs', {
      headers: {
        ...auth,
        Host: 'attacker.example',
        Origin: 'http://attacker.example',
        'Sec-Fetch-Site': 'same-origin',
        'X-Forwarded-For': '1.2.3.4',
        'X-Forwarded-Host': 'attacker.example',
        'Content-Type': 'application/json',
      },
      body: { oauthSkipChooser: true },
    });
    assert.equal(r.status, 403, r.raw);
    assert.equal(r.body?.error, 'ERR_DASHBOARD_HOST_REJECTED');
  });

  it('accepts a correctly sanitized proxy Host that is allowlisted', async () => {
    process.env.TRUST_PROXY_X_FORWARDED_FOR = '1';
    process.env.DASHBOARD_ALLOWED_HOSTS = PROXY_HOST;
    const auth = withPassword();
    const r = await request('PUT', '/dashboard/api/settings/prefs', {
      headers: {
        ...auth,
        Host: `127.0.0.1:${port}`,
        Origin: `http://${PROXY_HOST}`,
        'Sec-Fetch-Site': 'same-origin',
        'X-Forwarded-For': '1.2.3.4',
        'X-Forwarded-Host': PROXY_HOST,
        'Content-Type': 'application/json',
      },
      body: { oauthSkipChooser: true },
    });
    assert.equal(r.status, 200, r.raw);
    assert.equal(r.body?.success, true);
  });

  it('rejects the same attack request when proxy trust is off', async () => {
    process.env.DASHBOARD_ALLOWED_HOSTS = PROXY_HOST;
    const auth = withPassword();
    const r = await request('PUT', '/dashboard/api/settings/prefs', {
      headers: {
        ...auth,
        Host: 'attacker.example',
        Origin: 'http://attacker.example',
        'Sec-Fetch-Site': 'same-origin',
        'X-Forwarded-Host': 'attacker.example',
        'Content-Type': 'application/json',
      },
      body: { oauthSkipChooser: true },
    });
    assert.equal(r.status, 403, r.raw);
    assert.equal(r.body?.error, 'ERR_DASHBOARD_HOST_REJECTED');
  });

  it('rejects /auth/* with an unallowlisted forwarded Host under proxy trust', async () => {
    process.env.TRUST_PROXY_X_FORWARDED_FOR = '1';
    process.env.DASHBOARD_ALLOWED_HOSTS = PROXY_HOST;
    const auth = withPassword();
    const before = getAccountCount().total;
    const r = await request('POST', '/auth/login', {
      headers: {
        ...auth,
        Host: 'attacker.example',
        Origin: 'http://attacker.example',
        'Content-Type': 'text/plain',
        'X-Forwarded-For': '1.2.3.4',
        'X-Forwarded-Host': 'attacker.example',
      },
      body: { token: 'devin-session-token$host-boundary-probe-xfh' },
    });
    assert.equal(r.status, 403, r.raw);
    assert.equal(r.body?.error, 'ERR_DASHBOARD_HOST_REJECTED');
    assert.equal(getAccountCount().total, before);
  });

  it('accepts /auth/* through a correctly sanitized allowlisted proxy Host', async () => {
    process.env.TRUST_PROXY_X_FORWARDED_FOR = '1';
    process.env.DASHBOARD_ALLOWED_HOSTS = PROXY_HOST;
    const auth = withPassword();
    const r = await request('GET', '/auth/accounts', {
      headers: {
        ...auth,
        Host: `127.0.0.1:${port}`,
        'X-Forwarded-For': '1.2.3.4',
        'X-Forwarded-Host': PROXY_HOST,
      },
    });
    assert.equal(r.status, 200, r.raw);
    assert.ok(Array.isArray(r.body?.accounts));
  });
});