test/caller-key-xff-spoof.test.js
Rama master · réplica de lectura del repositorio original.
// 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;
}
});
});