Ctrl K
ComunidadGuías…

test/caller-key-xff-spoof.test.js

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

Archivo original ↗Preguntar a KiBot
// v2.0.55 audit H2 regression — X-Forwarded-For must NOT be trusted by
// default. An attacker with the shared API key cannot land in another
// caller's bucket by setting XFF + UA. The only way to enable XFF trust
// is `TRUST_PROXY_X_FORWARDED_FOR=1`, which operators behind a trusted
// reverse proxy may set.
//
// We exercise the env via dynamic import on a cleared module cache so
// the `TRUST_PROXY_XFF` constant captured at module-load picks up the
// test-set value.

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

function mkReq(xff, ip, ua = 'claude-cli/1.0') {
  return {
    headers: {
      'x-forwarded-for': xff,
      'user-agent': ua,
    },
    socket: { remoteAddress: ip },
  };
}

async function loadCallerKeyFresh(envValue) {
  // Module cache busting via query param. Each fresh import re-reads
  // process.env.TRUST_PROXY_X_FORWARDED_FOR at module-eval time.
  if (envValue === undefined) delete process.env.TRUST_PROXY_X_FORWARDED_FOR;
  else process.env.TRUST_PROXY_X_FORWARDED_FOR = envValue;
  const stamp = Date.now() + Math.random();
  return await import(`../src/caller-key.js?fresh=${stamp}`);
}

describe('callerKeyFromRequest — XFF spoofing guard (audit H2)', () => {
  it('default mode: spoofed XFF cannot collide with victim using attacker socket IP', async () => {
    const m = await loadCallerKeyFresh(undefined);
    const apiKey = 'sk-shared';
    // Victim hits the proxy directly from 203.0.113.1 — no XFF.
    const victim = m.callerKeyFromRequest(
      mkReq(undefined, '203.0.113.1'),
      apiKey,
      {},
    );
    // Attacker connects from 198.51.100.200 but spoofs XFF=203.0.113.1.
    // Without XFF trust the fingerprint should fall back to
    // socket.remoteAddress and stay distinct.
    const attacker = m.callerKeyFromRequest(
      mkReq('203.0.113.1', '198.51.100.200'),
      apiKey,
      {},
    );
    assert.notEqual(victim, attacker, 'attacker must not collide with victim by XFF spoof');
  });

  it('default mode: same socket IP+UA → same caller bucket (legitimate cross-turn reuse)', async () => {
    const m = await loadCallerKeyFresh(undefined);
    const a = m.callerKeyFromRequest(mkReq(undefined, '203.0.113.5'), 'sk', {});
    const b = m.callerKeyFromRequest(mkReq(undefined, '203.0.113.5'), 'sk', {});
    assert.equal(a, b, 'two calls from same IP+UA must share callerKey');
  });

  it('TRUST_PROXY_X_FORWARDED_FOR=1: XFF first hop is honoured (legacy reverse-proxy mode)', async () => {
    const m = await loadCallerKeyFresh('1');
    // When operator opts in, XFF first hop wins over socket.remoteAddress.
    const fromXff = m.callerKeyFromRequest(mkReq('203.0.113.1', '198.51.100.200'), 'sk', {});
    const fromSocket = m.callerKeyFromRequest(mkReq(undefined, '203.0.113.1'), 'sk', {});
    assert.equal(fromXff, fromSocket, 'XFF first hop must match direct socket IP under trust mode');
  });

  it('apiKey-less fallback path also respects the env gate', async () => {
    const m = await loadCallerKeyFresh(undefined);
    const victim = m.callerKeyFromRequest(mkReq(undefined, '203.0.113.7'), '', {});
    const attacker = m.callerKeyFromRequest(mkReq('203.0.113.7', '198.51.100.200'), '', {});
    assert.notEqual(victim, attacker, 'fallback path must not trust spoofed XFF either');
  });
});

describe('callerKeyFromRequest — XFF read from the RIGHT by trusted hops (audit P1 XFF-1)', () => {
  it('trust mode: leftmost (client-controlled) XFF value is IGNORED', async () => {
    const m = await loadCallerKeyFresh('1');
    // One trusted proxy (default hops=1). The proxy appended the real client
    // (203.0.113.9) on the right; the attacker prepended a spoof on the left.
    const spoofed = m.callerKeyFromRequest(mkReq('1.2.3.4, 203.0.113.9', '10.0.0.1'), 'sk', {});
    // Same real client, no spoof prefix.
    const honest = m.callerKeyFromRequest(mkReq('203.0.113.9', '10.0.0.1'), 'sk', {});
    assert.equal(spoofed, honest, 'left-prepended spoof must not change the bucket');
  });

  it('trust mode: attacker rotating the leftmost value cannot land in distinct buckets', async () => {
    const m = await loadCallerKeyFresh('1');
    const a = m.callerKeyFromRequest(mkReq('9.9.9.1, 203.0.113.9', '10.0.0.1'), 'sk', {});
    const b = m.callerKeyFromRequest(mkReq('9.9.9.2, 203.0.113.9', '10.0.0.1'), 'sk', {});
    assert.equal(a, b, 'rotating the spoofed prefix must not evade a per-IP bucket');
  });

  it('trust mode: honours TRUST_PROXY_HOPS=2 (two proxies append on the right)', async () => {
    process.env.TRUST_PROXY_HOPS = '2';
    try {
      const m = await loadCallerKeyFresh('1');
      // client, then proxy1, then proxy2 appended → real client is 3rd-from-left
      // = 2nd-from-right's predecessor. With hops=2 we take parts[len-2].
      const withSpoof = m.callerKeyFromRequest(mkReq('evil, 203.0.113.20, 172.16.0.1', '10.0.0.1'), 'sk', {});
      const honest = m.callerKeyFromRequest(mkReq('203.0.113.20, 172.16.0.1', '10.0.0.1'), 'sk', {});
      assert.equal(withSpoof, honest, 'hops=2 must pick the entry before the 2 trusted proxies');
    } finally {
      delete process.env.TRUST_PROXY_HOPS;
    }
  });

  it('trust mode: header shorter than hop count falls back to socket peer', async () => {
    process.env.TRUST_PROXY_HOPS = '3';
    try {
      const m = await loadCallerKeyFresh('1');
      const fromShortXff = m.callerKeyFromRequest(mkReq('203.0.113.30', '198.51.100.9'), 'sk', {});
      const fromSocket = m.callerKeyFromRequest(mkReq(undefined, '198.51.100.9'), 'sk', {});
      assert.equal(fromShortXff, fromSocket, 'untrustworthy short XFF must fall back to socket.remoteAddress');
    } finally {
      delete process.env.TRUST_PROXY_HOPS;
    }
  });
});