Ctrl K
ComunidadGuías…

test/dashboard-mutation-guard.test.js

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

Archivo original ↗Preguntar a KiBot
// A04 regression: open-local dashboard mutations must reject cross-site
// "simple" POST/PUT requests (text/plain, no preflight) while a same-origin
// JSON request from the real dashboard still works.
//
// Before the fix, DASHBOARD_ALLOW_NO_AUTH=1 on a loopback bind accepted
// `PUT /dashboard/api/experimental` with `Content-Type: text/plain` and an
// arbitrary Origin — a web page could mutate the victim's local service.
// The gate now runs before auth counters and before every route arm:
//   - Content-Type must be application/json for body-bearing mutations.
//   - Origin must agree with Host (or be an explicit CORS allowlist entry).
//   - Sec-Fetch-Site must be same-origin/none (not cross-site/same-site),
//     Sec-Fetch-Mode must not be no-cors/navigate.
//   - Host must be loopback on a loopback bind (DNS-rebinding defence).

import { afterEach, 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 { handleDashboardApi } from '../src/dashboard/api.js';
import { startServer } from '../src/server.js';
import { _resetRuntimeConfigForTests } from '../src/runtime-config.js';

const original = {
  dashboardPassword: config.dashboardPassword,
  apiKey: config.apiKey,
  allowNoAuth: process.env.DASHBOARD_ALLOW_NO_AUTH,
  allowedHosts: process.env.DASHBOARD_ALLOWED_HOSTS,
  corsOrigins: process.env.DASHBOARD_CORS_ORIGINS,
};

function restore(key, value) {
  if (value === undefined) delete process.env[key];
  else process.env[key] = value;
}

beforeEach(() => {
  _resetRuntimeConfigForTests();
  _resetLockoutForTests();
  config.dashboardPassword = '';
  config.apiKey = '';
  process.env.DASHBOARD_ALLOW_NO_AUTH = '1';
  delete process.env.DASHBOARD_ALLOWED_HOSTS;
  delete process.env.DASHBOARD_CORS_ORIGINS;
  configureBindHost('127.0.0.1');
});

afterEach(() => {
  _resetRuntimeConfigForTests();
  _resetLockoutForTests();
  config.dashboardPassword = original.dashboardPassword;
  config.apiKey = original.apiKey;
  restore('DASHBOARD_ALLOW_NO_AUTH', original.allowNoAuth);
  restore('DASHBOARD_ALLOWED_HOSTS', original.allowedHosts);
  restore('DASHBOARD_CORS_ORIGINS', original.corsOrigins);
  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(payload) {
      try { captured.body = JSON.parse(payload); } catch { captured.body = payload; }
    },
    setHeader(k, v) { captured.headers[k] = v; },
    on() {},
    write() {},
  };
  return { res, captured };
}

function mutationReq(headers = {}) {
  return {
    method: 'PUT',
    url: '/dashboard/api/experimental',
    headers: {
      host: '127.0.0.1:3003',
      ...headers,
    },
    socket: { remoteAddress: '127.0.0.1' },
  };
}

async function mutate(headers, body = { clineCompat: true }) {
  const { res, captured } = mkRes();
  await handleDashboardApi('PUT', '/experimental', body, mutationReq(headers), res);
  return captured;
}

describe('A04: dashboard mutation request gate', () => {
  it('rejects a cross-site text/plain simple POST/PUT with 403 or 415 before side effects', async () => {
    const captured = await mutate({
      'content-type': 'text/plain;charset=UTF-8',
      origin: 'https://evil.example',
      'sec-fetch-site': 'cross-site',
      'sec-fetch-mode': 'no-cors',
      'sec-fetch-dest': 'empty',
    });
    assert.ok(
      captured.status === 403 || captured.status === 415,
      `cross-site text/plain mutation must be 403/415, got ${captured.status}`,
    );
  });

  it('still accepts the dashboard same-origin JSON request', async () => {
    const captured = await mutate({
      'content-type': 'application/json; charset=utf-8',
      origin: 'http://127.0.0.1:3003',
      'sec-fetch-site': 'same-origin',
      'sec-fetch-mode': 'cors',
      'sec-fetch-dest': 'empty',
    });
    assert.equal(captured.status, 200, JSON.stringify(captured.body));
    assert.equal(captured.body.success, true);
    assert.equal(captured.body.flags.clineCompat, true);
  });

  it('rejects a DNS-rebinding Host even when Origin agrees with it', async () => {
    const captured = await mutate({
      host: 'attacker.example:3003',
      'content-type': 'application/json',
      origin: 'http://attacker.example:3003',
      'sec-fetch-site': 'same-origin',
      'sec-fetch-mode': 'cors',
      'sec-fetch-dest': 'empty',
    });
    assert.equal(captured.status, 403);
    assert.equal(captured.body.error, 'ERR_DASHBOARD_HOST_REJECTED');
  });

  it('rejects Sec-Fetch-Site: same-site (strict same-origin only)', async () => {
    const captured = await mutate({
      'content-type': 'application/json',
      origin: 'http://127.0.0.1:3003',
      'sec-fetch-site': 'same-site',
      'sec-fetch-mode': 'cors',
      'sec-fetch-dest': 'empty',
    });
    assert.equal(captured.status, 403);
    assert.equal(captured.body.error, 'ERR_DASHBOARD_FETCH_METADATA_REJECTED');
  });

  it('rejects a body-bearing mutation with no Content-Type on a real HTTP request', async () => {
    const captured = await mutate({
      'content-length': '20',
      origin: 'http://127.0.0.1:3003',
      'sec-fetch-site': 'same-origin',
      'sec-fetch-mode': 'cors',
      'sec-fetch-dest': 'empty',
    });
    assert.equal(captured.status, 415);
    assert.equal(captured.body.error, 'ERR_UNSUPPORTED_MEDIA_TYPE');
  });

  it('rejects a cross-origin JSON request that would otherwise pass content-type', async () => {
    const captured = await mutate({
      'content-type': 'application/json',
      origin: 'https://evil.example',
      'sec-fetch-site': 'cross-site',
      'sec-fetch-mode': 'cors',
      'sec-fetch-dest': 'empty',
    });
    assert.equal(captured.status, 403);
    assert.equal(captured.body.error, 'ERR_DASHBOARD_ORIGIN_REJECTED');
  });

  it('rejects a cross-site simple POST to /accounts before creating an account', async () => {
    const label = `csrf-post-${Date.now()}`;
    const before = getAccountCount().total;
    try {
      const { res, captured } = mkRes();
      await handleDashboardApi('POST', '/accounts', {
        api_key: `sk-${label}`,
        label,
      }, {
        method: 'POST',
        url: '/dashboard/api/accounts',
        headers: {
          host: '127.0.0.1:3003',
          'content-type': 'text/plain;charset=UTF-8',
          'content-length': '80',
          origin: 'https://evil.example',
          'sec-fetch-site': 'cross-site',
          'sec-fetch-mode': 'no-cors',
          'sec-fetch-dest': 'empty',
        },
        socket: { remoteAddress: '127.0.0.1' },
      }, res);
      assert.ok(
        captured.status === 403 || captured.status === 415,
        `cross-site text/plain POST must be 403/415, got ${captured.status}`,
      );
      assert.equal(getAccountCount().total, before, 'the rejected POST must not create an account');
    } finally {
      for (const account of getAccountList()) {
        if (String(account.label || '').startsWith('csrf-post-')) removeAccount(account.id);
      }
    }
  });

  it('rejects a DNS-rebinding Host on GET reads (import-local leaks keys)', async () => {
    const { res, captured } = mkRes();
    await handleDashboardApi('GET', '/accounts/import-local', {}, {
      method: 'GET',
      url: '/dashboard/api/accounts/import-local',
      headers: { host: 'attacker.example:3003' },
      socket: { remoteAddress: '127.0.0.1' },
    }, res);
    assert.equal(captured.status, 403);
    assert.equal(captured.body.error, 'ERR_DASHBOARD_HOST_REJECTED');
  });

  it('allows an operator-allowlisted cross-origin JSON dashboard', async () => {
    process.env.DASHBOARD_CORS_ORIGINS = 'https://ok.example';
    const captured = await mutate({
      'content-type': 'application/json',
      origin: 'https://ok.example',
      'sec-fetch-site': 'cross-site',
      'sec-fetch-mode': 'cors',
      'sec-fetch-dest': 'empty',
    });
    assert.equal(captured.status, 200, JSON.stringify(captured.body));
    assert.equal(captured.body.success, true);
  });

  it('keeps non-browser JSON clients (curl/scripts) usable', async () => {
    const captured = await mutate({ 'content-type': 'application/json' });
    assert.equal(captured.status, 200, JSON.stringify(captured.body));
    assert.equal(captured.body.success, true);
  });

  it('rejects the cross-site text/plain POST through the real HTTP server', async () => {
    const saved = { host: config.host, port: config.port };
    config.host = '127.0.0.1';
    config.port = 0;
    const server = startServer();
    await new Promise((resolve, reject) => {
      server.once('listening', resolve);
      server.once('error', reject);
    });
    try {
      const port = server.address().port;
      const result = await new Promise((resolve, reject) => {
        const payload = JSON.stringify({ api_key: `sk-real-http-${Date.now()}`, label: 'csrf-real-http' });
        const req = http.request({
          host: '127.0.0.1',
          port,
          path: '/dashboard/api/accounts',
          method: 'POST',
          headers: {
            'Content-Type': 'text/plain;charset=UTF-8',
            'Content-Length': Buffer.byteLength(payload),
            Origin: 'https://evil.example',
            'Sec-Fetch-Site': 'cross-site',
            'Sec-Fetch-Mode': 'no-cors',
            'Sec-Fetch-Dest': 'empty',
          },
        }, res => {
          let raw = '';
          res.on('data', chunk => { raw += chunk; });
          res.on('end', () => resolve({ status: res.statusCode, body: raw ? JSON.parse(raw) : null }));
        });
        req.on('error', reject);
        req.end(payload);
      });
      assert.ok(result.status === 403 || result.status === 415,
        `real-server cross-site text/plain POST must be 403/415, got ${result.status}`);
    } finally {
      await new Promise(resolve => server.close(resolve));
      config.host = saved.host;
      config.port = saved.port;
      configureBindHost('127.0.0.1');
    }
  });

  it('does not require a JSON content type on bodyless DELETE', async () => {
    const { res, captured } = mkRes();
    await handleDashboardApi('DELETE', '/experimental/conversation-pool', {}, mutationReq(), res);
    assert.equal(captured.status, 200, JSON.stringify(captured.body));
    assert.equal(captured.body.success, true);
  });
});