GitNexus/gitnexus/test/unit/middleware-trust-proxy.test.ts

191 lines
6.8 KiB
TypeScript

/**
* Unit Tests: GITNEXUS_TRUST_PROXY resolution
*
* Express accepts a boolean, a hop count, or a comma-separated proxy list for
* `trust proxy`, and compiles the value inside `app.set` — so an unvalidated
* env value takes the server down at startup, or (for a number it cannot
* range-check) silently trusts every hop. resolveTrustProxy validates first.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_TRUST_PROXY,
MAX_TRUST_PROXY_HOPS,
TRUST_PROXY_ENV,
resolveTrustProxy,
warnIfRateLimitKeysCollapse,
} from '../../src/server/middleware.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
// `logger` is a Proxy with only a `get` trap, so vi.spyOn cannot replace
// `warn` on it; the module's own capture helper redirects the destination.
let cap: LoggerCapture;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
});
const warnings = (): string[] =>
cap
.records()
.filter((r) => r.level === 40)
.map((r) => String(r.msg));
describe('resolveTrustProxy — accepted', () => {
it('falls back to the loopback-scoped default when unset or blank', () => {
expect(resolveTrustProxy(undefined)).toBe(DEFAULT_TRUST_PROXY);
expect(resolveTrustProxy('')).toBe(DEFAULT_TRUST_PROXY);
expect(resolveTrustProxy(' ')).toBe(DEFAULT_TRUST_PROXY);
expect(warnings()).toEqual([]);
});
it('accepts the default it falls back to, so the fallback can never throw', () => {
expect(() => resolveTrustProxy(DEFAULT_TRUST_PROXY)).not.toThrow();
expect(resolveTrustProxy(DEFAULT_TRUST_PROXY)).toBe(DEFAULT_TRUST_PROXY);
expect(warnings()).toEqual([]);
});
const hopCounts = Array.from({ length: MAX_TRUST_PROXY_HOPS }, (_, i) => i + 1);
it.each(hopCounts)('accepts hop count %i', (hops) => {
expect(() => resolveTrustProxy(String(hops))).not.toThrow();
expect(resolveTrustProxy(String(hops))).toBe(hops);
expect(warnings()).toEqual([]);
});
it('trims surrounding whitespace off a hop count', () => {
expect(resolveTrustProxy(' 2 ')).toBe(2);
});
// Express tests a hop count as `i < hops`, so 0 and false are the same
// setting. Rejecting 0 would fall back to a default that trusts more.
it('normalizes a hop count of 0 to false rather than rejecting it', () => {
expect(resolveTrustProxy('0')).toBe(false);
expect(warnings()).toEqual([]);
});
it.each([
['false', false],
['FALSE', false],
['no', false],
['NO', false],
['off', false],
['OFF', false],
] as const)('accepts %s as a boolean without warning', (raw, expected) => {
expect(resolveTrustProxy(raw)).toBe(expected);
expect(warnings()).toEqual([]);
});
it.each(['loopback', 'linklocal', 'uniquelocal', '10.0.0.0/8, 127.0.0.1'])(
'accepts the proxy list %s verbatim',
(raw) => {
expect(() => resolveTrustProxy(raw)).not.toThrow();
expect(resolveTrustProxy(raw)).toBe(raw);
expect(warnings()).toEqual([]);
},
);
});
// `true` trusts every hop, which makes req.ip the client-controlled leftmost
// X-Forwarded-For entry — a fresh rate-limit key per spoofed request, in front
// of the two routes that spawn workers. express-rate-limit's own
// validations.trustProxy throws ERR_ERL_PERMISSIVE_TRUST_PROXY on it, so it was
// never a working configuration either. Rejected, not warned.
describe('resolveTrustProxy — rejects a trust-everything value', () => {
it.each(['true', 'TRUE', 'yes', 'YES', 'on', 'ON'])('falls back to the default on %s', (raw) => {
expect(resolveTrustProxy(raw)).toBe(DEFAULT_TRUST_PROXY);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(raw);
expect(warned[0]).toContain('X-Forwarded-For');
});
it('never returns true, so express-rate-limit cannot reject the value we set', () => {
for (const raw of ['true', 'yes', 'on', 'TRUE', '1', '16', 'loopback', '0', 'false']) {
expect(resolveTrustProxy(raw)).not.toBe(true);
}
});
});
describe('resolveTrustProxy — rejected', () => {
it.each([
['garbage', 'an unknown subnet name'],
['*', 'a wildcard'],
['9'.repeat(400), 'a hop count that overflows to Infinity'],
[String(MAX_TRUST_PROXY_HOPS + 1), 'a hop count above the range'],
['-1', 'a negative hop count'],
['1.5', 'a fractional hop count'],
['a.com;b.com', 'a semicolon-separated list'],
])('falls back to the default on %#: %s', (raw) => {
expect(resolveTrustProxy(raw)).toBe(DEFAULT_TRUST_PROXY);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(raw);
});
});
// resolveTrustProxy sees only the env value; whether the default is about to
// collapse the per-IP rate limit to one global limit depends on what we bound.
describe('warnIfRateLimitKeysCollapse', () => {
const original = process.env[TRUST_PROXY_ENV];
beforeEach(() => {
delete process.env[TRUST_PROXY_ENV];
});
afterEach(() => {
if (original === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = original;
});
it.each(['localhost', '127.0.0.1', '::1', '[::1]'])('stays silent on a %s bind', (host) => {
warnIfRateLimitKeysCollapse(host);
expect(warnings()).toEqual([]);
});
it.each([undefined, ''])('stays silent when no host is given (%o)', (host) => {
warnIfRateLimitKeysCollapse(host);
expect(warnings()).toEqual([]);
});
it.each([
['0.0.0.0', 'a wildcard bind accepts LB traffic too'],
['::', 'the IPv6 wildcard likewise'],
['192.168.1.10', 'a LAN bind'],
['203.0.113.7', 'a public bind'],
])('warns on %s (%s)', (host) => {
warnIfRateLimitKeysCollapse(host);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(host);
expect(warned[0]).toContain('one shared limit');
});
it.each(['1', 'loopback', 'garbage'])(
'stays silent when %s is configured, valid or not',
(raw) => {
// An invalid value is resolveTrustProxy's warning to make, not a second one
// here — the operator has already been told about that value.
process.env[TRUST_PROXY_ENV] = raw;
warnIfRateLimitKeysCollapse('0.0.0.0');
expect(warnings()).toEqual([]);
},
);
it('treats a whitespace-only value as unset', () => {
process.env[TRUST_PROXY_ENV] = ' ';
warnIfRateLimitKeysCollapse('0.0.0.0');
expect(warnings()).toHaveLength(1);
});
});
describe('resolveTrustProxy — contract', () => {
it('names the env var it reads', () => {
expect(TRUST_PROXY_ENV).toBe('GITNEXUS_TRUST_PROXY');
});
it('defaults to loopback plus the private ranges', () => {
expect(DEFAULT_TRUST_PROXY).toBe('loopback, linklocal, uniquelocal');
});
});