refactor(gateway): harden proxy client ip resolution

This commit is contained in:
Peter Steinberger
2026-02-21 13:32:25 +01:00
parent 8b1fe0d1e2
commit be7f825006
15 changed files with 246 additions and 87 deletions

View File

@@ -5,7 +5,7 @@ import {
isSecureWebSocketUrl,
isTrustedProxyAddress,
pickPrimaryLanIPv4,
resolveGatewayClientIp,
resolveClientIp,
resolveGatewayListenHosts,
resolveHostName,
} from "./net.js";
@@ -132,49 +132,74 @@ describe("isTrustedProxyAddress", () => {
});
});
describe("resolveGatewayClientIp", () => {
it("returns remote IP when the remote is not a trusted proxy", () => {
const ip = resolveGatewayClientIp({
describe("resolveClientIp", () => {
it.each([
{
name: "returns remote IP when remote is not trusted proxy",
remoteAddr: "203.0.113.10",
forwardedFor: "10.0.0.2",
trustedProxies: ["127.0.0.1"],
});
expect(ip).toBe("203.0.113.10");
});
it("returns forwarded client IP when the remote is a trusted proxy", () => {
const ip = resolveGatewayClientIp({
remoteAddr: "127.0.0.1",
forwardedFor: "127.0.0.1, 10.0.0.2",
trustedProxies: ["127.0.0.1"],
});
expect(ip).toBe("10.0.0.2");
});
it("does not trust the left-most X-Forwarded-For value when behind a trusted proxy", () => {
const ip = resolveGatewayClientIp({
expected: "203.0.113.10",
},
{
name: "uses right-most untrusted X-Forwarded-For hop",
remoteAddr: "127.0.0.1",
forwardedFor: "198.51.100.99, 10.0.0.9, 127.0.0.1",
trustedProxies: ["127.0.0.1"],
});
expect(ip).toBe("10.0.0.9");
});
it("fails closed when trusted proxy headers are missing", () => {
const ip = resolveGatewayClientIp({
expected: "10.0.0.9",
},
{
name: "fails closed when all X-Forwarded-For hops are trusted proxies",
remoteAddr: "127.0.0.1",
forwardedFor: "127.0.0.1, ::1",
trustedProxies: ["127.0.0.1", "::1"],
expected: undefined,
},
{
name: "fails closed when trusted proxy omits forwarding headers",
remoteAddr: "127.0.0.1",
trustedProxies: ["127.0.0.1"],
});
expect(ip).toBeUndefined();
});
it("supports IPv6 client IP forwarded by a trusted proxy", () => {
const ip = resolveGatewayClientIp({
expected: undefined,
},
{
name: "ignores invalid X-Forwarded-For entries",
remoteAddr: "127.0.0.1",
forwardedFor: "garbage, 10.0.0.999",
trustedProxies: ["127.0.0.1"],
expected: undefined,
},
{
name: "does not trust X-Real-IP by default",
remoteAddr: "127.0.0.1",
realIp: "[2001:db8::5]",
trustedProxies: ["127.0.0.1"],
expected: undefined,
},
{
name: "uses X-Real-IP only when explicitly enabled",
remoteAddr: "127.0.0.1",
realIp: "[2001:db8::5]",
trustedProxies: ["127.0.0.1"],
allowRealIpFallback: true,
expected: "2001:db8::5",
},
{
name: "ignores invalid X-Real-IP even when fallback enabled",
remoteAddr: "127.0.0.1",
realIp: "not-an-ip",
trustedProxies: ["127.0.0.1"],
allowRealIpFallback: true,
expected: undefined,
},
])("$name", (testCase) => {
const ip = resolveClientIp({
remoteAddr: testCase.remoteAddr,
forwardedFor: testCase.forwardedFor,
realIp: testCase.realIp,
trustedProxies: testCase.trustedProxies,
allowRealIpFallback: testCase.allowRealIpFallback,
});
expect(ip).toBe("2001:db8::5");
expect(ip).toBe(testCase.expected);
});
});