Files
SnapOtter/tests/unit/security/trust-proxy-resolution.test.ts
T

140 lines
5.6 KiB
TypeScript
Raw Normal View History

import Fastify from "fastify";
import { describe, expect, it } from "vitest";
import { DEFAULT_TRUST_PROXY, parseTrustProxy } from "../../../apps/api/src/lib/trust-proxy.js";
/**
* Contract tests for the TRUST_PROXY parser.
*
* `parseTrustProxy` turns the raw env string into the value Fastify's
* `trustProxy` option accepts. It used to be a private function inside
* apps/api/src/index.ts, which meant the only way to exercise it was to boot
* the whole server. It is extracted so the security suite can pin its
* behaviour directly.
*
* See finding SEC-20260726-002.
*/
describe("parseTrustProxy", () => {
it("maps the boolean literals to booleans", () => {
expect(parseTrustProxy("true")).toBe(true);
expect(parseTrustProxy("false")).toBe(false);
});
it("maps a numeric string to a hop count", () => {
expect(parseTrustProxy("1")).toBe(1);
expect(parseTrustProxy("3")).toBe(3);
});
it("passes a CIDR or named-range list through untouched", () => {
expect(parseTrustProxy("loopback,linklocal,uniquelocal")).toBe(
"loopback,linklocal,uniquelocal",
);
expect(parseTrustProxy("10.0.0.0/8")).toBe("10.0.0.0/8");
expect(parseTrustProxy("127.0.0.1,192.168.1.0/24")).toBe("127.0.0.1,192.168.1.0/24");
});
it("turns a blank value into zero hops, which trusts nothing", () => {
// Number("") and Number(" ") are both 0, so a blank TRUST_PROXY falls
// into the hop-count branch. Zod's .default() only fires on undefined, not
// on an empty string, so `TRUST_PROXY=` in a Compose file lands here.
// Pinned rather than "fixed": 0 hops means proxy-addr trusts no peer and
// request.ip stays the socket address, so the blank case already fails
// closed. Changing it would be a behaviour change dressed up as tidying.
expect(parseTrustProxy("")).toBe(0);
expect(parseTrustProxy(" ")).toBe(0);
});
});
/**
* Behavioural tests: does a forged X-Forwarded-For actually move request.ip?
*
* The parser tests above only prove the string is passed through. What matters
* for SEC-20260726-002 is what Fastify then does with it, so these boot a real
* Fastify instance configured exactly the way apps/api/src/index.ts configures
* the server and ask it what request.ip resolved to.
*
* `remoteAddress` is the socket peer, i.e. who is actually connecting. The
* X-Forwarded-For value is what that peer claims the client was.
*/
/** Resolve request.ip for a given trustProxy setting, peer, and forged header. */
async function resolveIp(
trustProxy: boolean | number | string,
remoteAddress: string,
forwardedFor?: string,
): Promise<string> {
const app = Fastify({ trustProxy });
app.get("/whoami", async (request) => ({ ip: request.ip }));
try {
const res = await app.inject({
method: "GET",
url: "/whoami",
remoteAddress,
headers: forwardedFor ? { "x-forwarded-for": forwardedFor } : {},
});
return JSON.parse(res.body).ip as string;
} finally {
await app.close();
}
}
const PUBLIC_PEER = "203.0.113.9"; // TEST-NET-3, i.e. not a private range
const FORGED = "198.51.100.77"; // TEST-NET-2, what an attacker would claim
describe("request.ip resolution under the shipped TRUST_PROXY default", () => {
it("ignores a forged X-Forwarded-For from an untrusted public peer", async () => {
// The whole point of the finding: a directly exposed instance must not let
// a client pick its own rate-limit bucket.
const ip = await resolveIp(parseTrustProxy(DEFAULT_TRUST_PROXY), PUBLIC_PEER, FORGED);
expect(ip).toBe(PUBLIC_PEER);
expect(ip).not.toBe(FORGED);
});
it("still believes a reverse proxy on a private network", async () => {
// A proxy on a Docker network or a LAN holds an RFC1918 address. Preserving
// this case is why the default is a trust list rather than `false`.
const ip = await resolveIp(parseTrustProxy(DEFAULT_TRUST_PROXY), "172.18.0.1", FORGED);
expect(ip).toBe(FORGED);
});
it("still believes a reverse proxy on loopback", async () => {
const ip = await resolveIp(parseTrustProxy(DEFAULT_TRUST_PROXY), "127.0.0.1", FORGED);
expect(ip).toBe(FORGED);
});
it("falls back to the peer address when a trusted proxy sends no header", async () => {
const ip = await resolveIp(parseTrustProxy(DEFAULT_TRUST_PROXY), "10.1.2.3");
expect(ip).toBe("10.1.2.3");
});
it("takes the rightmost untrusted hop when a chain is forged behind a real proxy", async () => {
// A trusted proxy appends the peer it saw. An attacker prepends whatever it
// likes. proxy-addr walks right to left and stops at the first untrusted
// entry, so the attacker's prefix is discarded rather than believed.
const ip = await resolveIp(
parseTrustProxy(DEFAULT_TRUST_PROXY),
"172.18.0.1",
`${FORGED}, ${PUBLIC_PEER}`,
);
expect(ip).toBe(PUBLIC_PEER);
expect(ip).not.toBe(FORGED);
});
});
describe("the old default is what made request.ip client-controlled", () => {
it("TRUST_PROXY=true believes a forged header from any peer", async () => {
// Pins the mechanism the fix removes. If this ever stops being true the
// finding's reproduction no longer describes reality and the tests above
// would be proving nothing.
const ip = await resolveIp(parseTrustProxy("true"), PUBLIC_PEER, FORGED);
expect(ip).toBe(FORGED);
});
it("TRUST_PROXY=false ignores every proxy, private ones included", async () => {
// The rejected alternative: closes the hole, but collapses a proxied
// deployment into one rate-limit bucket keyed on the proxy.
const ip = await resolveIp(parseTrustProxy("false"), "172.18.0.1", FORGED);
expect(ip).toBe("172.18.0.1");
});
});