mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Forwarding headers were believed from any connection on a private address. Behind Docker Desktop, OrbStack and docker-proxy every connection to a published port arrives from the gateway, a private address, so a caller reaching the port directly could again choose the client address that rate limits, the audit log and sign-in notices record. TRUSTED_PROXY_CIDRS (comma-separated CIDRs) now names the proxies: when set, only those peers may report a client address. The private-range default stays when it is unset, the boot log states the rule in force in one line, and the reverse-proxy guide says what to set for a proxy on the same host, on a Docker network, or on a public address (which, unlisted, puts all clients in one bucket). An invalid TRUST_PROXY_HOPS threw on every request that resolved the client address, so every route answered 500, the health probe included. It now resolves no address, warns once, and is named at boot.
593 lines
21 KiB
TypeScript
593 lines
21 KiB
TypeScript
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
beforeEach,
|
|
afterEach,
|
|
onTestFinished,
|
|
vi,
|
|
} from "vitest";
|
|
import {
|
|
getClientIp,
|
|
getClientIpOrTrustWarning,
|
|
_resetInvalidHopsWarningForTests,
|
|
_resetPublicPeerForwardingWarningForTests,
|
|
_resetTrustViolationWarningForTests,
|
|
_resetUnreadProxyHeadersWarningForTests,
|
|
} from "../api-response";
|
|
import { describeProxyTrust } from "../http/proxy-trust";
|
|
|
|
const ORIGINAL_HOPS = process.env.TRUST_PROXY_HOPS;
|
|
const ORIGINAL_CF_FLAG = process.env.TRUST_CF_CONNECTING_IP;
|
|
const ORIGINAL_CIDRS = process.env.TRUSTED_PROXY_CIDRS;
|
|
|
|
beforeEach(() => {
|
|
delete process.env.TRUST_PROXY_HOPS;
|
|
delete process.env.TRUST_CF_CONNECTING_IP;
|
|
delete process.env.TRUSTED_PROXY_CIDRS;
|
|
_resetPublicPeerForwardingWarningForTests();
|
|
_resetTrustViolationWarningForTests();
|
|
_resetUnreadProxyHeadersWarningForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL_HOPS === undefined) delete process.env.TRUST_PROXY_HOPS;
|
|
else process.env.TRUST_PROXY_HOPS = ORIGINAL_HOPS;
|
|
if (ORIGINAL_CF_FLAG === undefined) delete process.env.TRUST_CF_CONNECTING_IP;
|
|
else process.env.TRUST_CF_CONNECTING_IP = ORIGINAL_CF_FLAG;
|
|
if (ORIGINAL_CIDRS === undefined) delete process.env.TRUSTED_PROXY_CIDRS;
|
|
else process.env.TRUSTED_PROXY_CIDRS = ORIGINAL_CIDRS;
|
|
});
|
|
|
|
function makeRequest(headers: Record<string, string>): Request {
|
|
return new Request("https://example.com/", { headers });
|
|
}
|
|
|
|
// V3 audit: client-supplied X-Forwarded-For was being trusted blindly,
|
|
// allowing a per-request `XFF: 1.2.3.<rand>` to rotate the bucket of any
|
|
// per-IP rate-limit. The new contract reads XFF right-to-left with a
|
|
// configurable number of trusted hops (Express semantics).
|
|
describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
|
it("defaults to 1 trusted hop — returns the rightmost XFF entry", () => {
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("with TRUST_PROXY_HOPS=2 returns the second-from-rightmost", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("1.2.3.4");
|
|
});
|
|
|
|
it("with TRUST_PROXY_HOPS=2 and a chain shorter than 2 returns null (fixes review M-3)", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
// Misconfigured deployment: claims 2 trusted hops but only 1 proxy is
|
|
// in the chain. Falling back to the leftmost (attacker-controlled)
|
|
// entry would re-introduce XFF rotation. Refuse the chain entirely.
|
|
const ip = getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
expect(ip).toBeNull();
|
|
});
|
|
|
|
it("resolves no address for an unparseable TRUST_PROXY_HOPS instead of failing the request", () => {
|
|
// It used to throw on every request that asked for the client address,
|
|
// so every route answered 500, the health probe included. Reading the
|
|
// headers anyway would silently trust a chain the operator never
|
|
// described (review L-1), so: no address, a trust violation, one warning.
|
|
process.env.TRUST_PROXY_HOPS = "garbage";
|
|
_resetInvalidHopsWarningForTests();
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
onTestFinished(() => warn.mockRestore());
|
|
const request = makeRequest({ "x-forwarded-for": "5.6.7.8" });
|
|
expect(getClientIpOrTrustWarning(request)).toEqual({
|
|
ip: null,
|
|
trustViolation: true,
|
|
});
|
|
expect(getClientIp(request)).toBeNull();
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0]![0]).toMatch(/TRUST_PROXY_HOPS="garbage"/);
|
|
});
|
|
|
|
it.each(["", " "])(
|
|
"treats an empty TRUST_PROXY_HOPS (%j) as unset — the default compose file passes one",
|
|
(value) => {
|
|
process.env.TRUST_PROXY_HOPS = value;
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
},
|
|
);
|
|
|
|
it("with TRUST_PROXY_HOPS=0 trusts no forwarding header, x-real-ip included", () => {
|
|
process.env.TRUST_PROXY_HOPS = "0";
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"x-forwarded-for": "9.9.9.9, 1.2.3.4",
|
|
"x-real-ip": "8.8.8.8",
|
|
}),
|
|
);
|
|
expect(ip).toBeNull();
|
|
});
|
|
|
|
it("x-real-ip rotation — with no declared proxy a caller cannot mint a fresh bucket per request", () => {
|
|
process.env.TRUST_PROXY_HOPS = "0";
|
|
const seen = new Set(
|
|
["1.1.1.1", "2.2.2.2", "3.3.3.3"].map((ip) =>
|
|
getClientIp(makeRequest({ "x-real-ip": ip })),
|
|
),
|
|
);
|
|
expect(seen).toEqual(new Set([null]));
|
|
});
|
|
|
|
it("with TRUST_PROXY_HOPS=0 and no x-real-ip returns null", () => {
|
|
process.env.TRUST_PROXY_HOPS = "0";
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4" }),
|
|
);
|
|
expect(ip).toBeNull();
|
|
});
|
|
|
|
it("XFF rotation attack — caller cannot bypass per-IP rate-limit by changing leftmost entry", () => {
|
|
// Attacker sends successive requests with rotating leftmost XFF; the
|
|
// proxy still appends the real client IP at the end, so the helper
|
|
// returns the same IP regardless of attacker chosen XFF.
|
|
const a = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "1.1.1.1, 203.0.113.5" }),
|
|
);
|
|
const b = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "8.8.8.8, 203.0.113.5" }),
|
|
);
|
|
expect(a).toBe("203.0.113.5");
|
|
expect(b).toBe("203.0.113.5");
|
|
});
|
|
|
|
it("rejects malformed entries in the chain", () => {
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "garbage,, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
// v1.4.38 — looksLikeIp now defers to node:net's `isIP` instead of
|
|
// a hex/dot character-class regex. Structurally invalid candidates
|
|
// (`:::`, `1.2`, `gg:hh::1`) used to slip through the regex and
|
|
// pollute the rate-limit / audit log with non-IP strings; they are
|
|
// now rejected at the helper.
|
|
it("rejects structurally invalid IPv4 candidates (1.2)", () => {
|
|
const ip = getClientIp(makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }));
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("rejects structurally invalid IPv6 candidates (:::)", () => {
|
|
const ip = getClientIp(makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }));
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("rejects hex-but-not-IPv6 chains", () => {
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "gg:hh::1, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("accepts well-formed IPv6 addresses", () => {
|
|
process.env.TRUST_PROXY_HOPS = "1";
|
|
const ip = getClientIp(makeRequest({ "x-forwarded-for": "2001:db8::1" }));
|
|
expect(ip).toBe("2001:db8::1");
|
|
});
|
|
|
|
it("falls back to x-real-ip if XFF missing", () => {
|
|
const ip = getClientIp(makeRequest({ "x-real-ip": "5.6.7.8" }));
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* F-6 (mobile security audit, 2026-05-16): when `TRUST_PROXY_HOPS` and
|
|
* the actual proxy chain don't agree, getClientIp returns null and every
|
|
* caller falls back to a literal `"unknown"` rate-limit bucket. The
|
|
* operator needs a single warning per process so the dashboards reflect
|
|
* the misconfiguration, and a new `getClientIpOrTrustWarning` helper
|
|
* lets future callers route the request to a tighter universal bucket.
|
|
*/
|
|
describe("getClientIp trust-violation warning (F-6, 2026-05-16)", () => {
|
|
it("emits a console.warn once when the chain is shorter than configured hops", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0][0]).toMatch(/TRUST_PROXY_HOPS=2/);
|
|
expect(warn.mock.calls[0][0]).toMatch(/1 entry/);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("does not warn when the chain length matches", () => {
|
|
process.env.TRUST_PROXY_HOPS = "1";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
expect(warn).not.toHaveBeenCalled();
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.4.37 — Cloudflare's `cf-connecting-ip` carries the visitor IP
|
|
* for every request that lands on its edge. The Coolify-fronted
|
|
* HealthLog stack sits behind Cloudflare; without consulting this
|
|
* header, the admin sign-in geo lookup had no signal to resolve from
|
|
* because XFF stops at the Caddy loopback. The header is honoured
|
|
* only under `TRUST_CF_CONNECTING_IP=1` so a self-hosted deployment
|
|
* without Cloudflare in front cannot be tricked by an attacker
|
|
* setting the header directly.
|
|
*/
|
|
describe("X-Real-IP with two or more hops, and headers the trust cannot read", () => {
|
|
it("does not read X-Real-IP when two hops are declared and XFF is missing", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
// A caller behind the outer proxy could have written it.
|
|
expect(getClientIp(makeRequest({ "x-real-ip": "9.9.9.9" }))).toBeNull();
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0][0]).toMatch(/X-Real-IP/);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("warns once when TRUST_PROXY_HOPS=0 and a proxy sends only X-Real-IP", () => {
|
|
process.env.TRUST_PROXY_HOPS = "0";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
for (let i = 0; i < 3; i++) {
|
|
expect(
|
|
getClientIp(makeRequest({ "x-real-ip": `10.0.0.${i}` })),
|
|
).toBeNull();
|
|
}
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0][0]).toMatch(/TRUST_PROXY_HOPS=0/);
|
|
expect(warn.mock.calls[0][0]).toMatch(/TRUST_PROXY_HOPS=1/);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("stays quiet with TRUST_PROXY_HOPS=0 and no forwarding header", () => {
|
|
process.env.TRUST_PROXY_HOPS = "0";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
expect(getClientIp(makeRequest({}))).toBeNull();
|
|
expect(warn).not.toHaveBeenCalled();
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getClientIp Cloudflare cf-connecting-ip branch (v1.4.37)", () => {
|
|
it("returns cf-connecting-ip when the env flag is on and the header is present", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"cf-connecting-ip": "203.0.113.42",
|
|
"x-forwarded-for": "1.1.1.1, 192.0.2.7",
|
|
}),
|
|
);
|
|
expect(ip).toBe("203.0.113.42");
|
|
});
|
|
|
|
it("ignores cf-connecting-ip when the env flag is off (default)", () => {
|
|
// Default — header set by an attacker on a deployment without
|
|
// Cloudflare in front must NOT be trusted; the helper falls
|
|
// through to the XFF / x-real-ip chain.
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"cf-connecting-ip": "203.0.113.42",
|
|
"x-forwarded-for": "9.9.9.9, 5.6.7.8",
|
|
}),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("ignores cf-connecting-ip when the env flag is any value other than '1'", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "true";
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"cf-connecting-ip": "203.0.113.42",
|
|
"x-forwarded-for": "9.9.9.9, 5.6.7.8",
|
|
}),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("falls back to the XFF chain when the env flag is on but cf-connecting-ip is missing", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "1.1.1.1, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("rejects a malformed cf-connecting-ip when the env flag is on", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"cf-connecting-ip": "<<not-an-ip>>",
|
|
"x-forwarded-for": "1.1.1.1, 5.6.7.8",
|
|
}),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("getClientIpOrTrustWarning also prefers cf-connecting-ip when the flag is on", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({
|
|
"cf-connecting-ip": "203.0.113.42",
|
|
"x-forwarded-for": "1.1.1.1",
|
|
}),
|
|
);
|
|
expect(result).toEqual({ ip: "203.0.113.42", trustViolation: false });
|
|
});
|
|
});
|
|
|
|
describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => {
|
|
it("returns trustViolation=false on a well-formed chain", () => {
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({ "x-forwarded-for": "1.2.3.4, 5.6.7.8" }),
|
|
);
|
|
expect(result).toEqual({ ip: "5.6.7.8", trustViolation: false });
|
|
});
|
|
|
|
it("returns trustViolation=true when the chain is shorter than hops", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
|
);
|
|
expect(result.trustViolation).toBe(true);
|
|
// ip falls back to x-real-ip (absent here) so it's null
|
|
expect(result.ip).toBeNull();
|
|
});
|
|
|
|
it("returns trustViolation=true and ignores x-real-ip when the chain is short", () => {
|
|
// The declared proxies were bypassed, so x-real-ip came from the caller.
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({
|
|
"x-forwarded-for": "5.6.7.8",
|
|
"x-real-ip": "9.9.9.9",
|
|
}),
|
|
);
|
|
expect(result).toEqual({ ip: null, trustViolation: true });
|
|
});
|
|
|
|
it("returns trustViolation=false when XFF is absent (no chain to validate)", () => {
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({ "x-real-ip": "9.9.9.9" }),
|
|
);
|
|
expect(result).toEqual({ ip: "9.9.9.9", trustViolation: false });
|
|
});
|
|
|
|
it("emits the same once-per-process warning when triggered via the tagged helper", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
getClientIpOrTrustWarning(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
getClientIpOrTrustWarning(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A caller that reaches the server directly used to choose its own address:
|
|
* one trusted hop is the default, and the rightmost X-Forwarded-For entry is
|
|
* whatever the caller wrote when no proxy appended to it. Every per-IP limit
|
|
* became per-request. The server now stamps each request with its
|
|
* connection's peer (`@/lib/http/peer-address`), and forwarding headers are
|
|
* read only from a peer that could be a proxy.
|
|
*/
|
|
describe("getClientIp against the connection's own peer", () => {
|
|
const stamped = (peer: string, headers: Record<string, string> = {}) =>
|
|
makeRequest({ ...headers, "x-healthlog-peer-address": peer });
|
|
|
|
it("ignores forwarding headers from a public peer, however they rotate", () => {
|
|
const ips = ["203.0.113.1", "203.0.113.2", "203.0.113.3"].map((forged) =>
|
|
getClientIp(
|
|
stamped("198.51.100.7", {
|
|
"x-forwarded-for": forged,
|
|
"x-real-ip": forged,
|
|
}),
|
|
),
|
|
);
|
|
expect(ips).toEqual(["198.51.100.7", "198.51.100.7", "198.51.100.7"]);
|
|
});
|
|
|
|
it("reads forwarding headers from a proxy on a private network or loopback", () => {
|
|
for (const proxy of ["172.18.0.2", "127.0.0.1", "::1", "100.101.102.103"]) {
|
|
expect(
|
|
getClientIp(stamped(proxy, { "x-forwarded-for": "203.0.113.5" })),
|
|
).toBe("203.0.113.5");
|
|
}
|
|
expect(
|
|
getClientIp(stamped("10.0.0.2", { "x-real-ip": "203.0.113.6" })),
|
|
).toBe("203.0.113.6");
|
|
});
|
|
|
|
// The ranges docs/self-hosting/reverse-proxy.md lists, one address each.
|
|
// Reserved and multicast stay in: no host on the internet can complete a
|
|
// TCP handshake from them, so a peer there is on a network the operator
|
|
// runs, like a private one.
|
|
it("reads forwarding headers from every range the reverse-proxy page lists", () => {
|
|
for (const proxy of [
|
|
"127.0.0.2", // loopback
|
|
"0.0.0.1", // 0.0.0.0/8
|
|
"10.1.2.3", // private
|
|
"172.31.0.9",
|
|
"192.168.1.20",
|
|
"100.64.0.1", // CGNAT (Tailscale)
|
|
"169.254.10.10", // link-local
|
|
"192.0.0.8", // IETF protocol assignments
|
|
"198.18.0.4", // benchmarking
|
|
"240.0.0.1", // reserved
|
|
"fd7a:115c:a1e0::1", // unique-local (Tailscale)
|
|
"fe80::1", // link-local
|
|
"fec0::1", // site-local
|
|
"::ffff:10.0.0.3", // IPv4-mapped private
|
|
]) {
|
|
expect(
|
|
getClientIp(stamped(proxy, { "x-forwarded-for": "203.0.113.7" })),
|
|
proxy,
|
|
).toBe("203.0.113.7");
|
|
}
|
|
for (const outside of ["8.8.8.8", "2001:4860:4860::8888"]) {
|
|
expect(
|
|
getClientIp(stamped(outside, { "x-forwarded-for": "203.0.113.7" })),
|
|
).toBe(outside);
|
|
}
|
|
});
|
|
|
|
it("believes cf-connecting-ip from any peer once the flag is on, which is why the origin must take Cloudflare only", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
expect(
|
|
getClientIp(
|
|
stamped("198.51.100.7", { "cf-connecting-ip": "203.0.113.9" }),
|
|
),
|
|
).toBe("203.0.113.9");
|
|
});
|
|
|
|
it("reads an IPv4 peer that the socket reports in IPv6 form", () => {
|
|
expect(
|
|
getClientIp(
|
|
stamped("::ffff:198.51.100.7", { "x-forwarded-for": "203.0.113.1" }),
|
|
),
|
|
).toBe("198.51.100.7");
|
|
});
|
|
|
|
it("uses the peer itself with no proxy declared, instead of nothing", () => {
|
|
process.env.TRUST_PROXY_HOPS = "0";
|
|
expect(
|
|
getClientIp(
|
|
stamped("198.51.100.7", { "x-forwarded-for": "203.0.113.1" }),
|
|
),
|
|
).toBe("198.51.100.7");
|
|
});
|
|
|
|
it("trusts no forwarding header on a request the server did not stamp, once it stamps", async () => {
|
|
const { installPeerAddressStamp, __uninstallPeerAddressStampForTests } =
|
|
await import("../http/peer-address");
|
|
installPeerAddressStamp();
|
|
onTestFinished(__uninstallPeerAddressStampForTests);
|
|
expect(
|
|
getClientIp(makeRequest({ "x-forwarded-for": "203.0.113.1" })),
|
|
).toBeNull();
|
|
// A stamp that is not an address (the socket was already gone).
|
|
expect(
|
|
getClientIp(stamped("", { "x-forwarded-for": "203.0.113.1" })),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Behind Docker Desktop, OrbStack or `docker-proxy`, every connection to a
|
|
* published port arrives from the gateway, a private address, so the
|
|
* private-range default believed a direct caller's forwarding headers again.
|
|
* `TRUSTED_PROXY_CIDRS` names the proxies; no other peer may forward.
|
|
*/
|
|
describe("getClientIp with TRUSTED_PROXY_CIDRS", () => {
|
|
const stamped = (peer: string, headers: Record<string, string> = {}) =>
|
|
makeRequest({ ...headers, "x-healthlog-peer-address": peer });
|
|
|
|
it("reads forwarding headers only from a peer inside the list", () => {
|
|
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16, 127.0.0.1";
|
|
expect(
|
|
getClientIp(stamped("172.18.0.5", { "x-forwarded-for": "203.0.113.5" })),
|
|
).toBe("203.0.113.5");
|
|
expect(
|
|
getClientIp(stamped("127.0.0.1", { "x-real-ip": "203.0.113.6" })),
|
|
).toBe("203.0.113.6");
|
|
});
|
|
|
|
it("uses the gateway's own address for a direct caller from a private network outside the list", () => {
|
|
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16";
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const forged = ["203.0.113.1", "203.0.113.2"].map((ip) =>
|
|
getClientIp(
|
|
stamped("192.168.65.1", { "x-forwarded-for": ip, "x-real-ip": ip }),
|
|
),
|
|
);
|
|
expect(forged).toEqual(["192.168.65.1", "192.168.65.1"]);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("believes a proxy on a public address once it is listed", () => {
|
|
process.env.TRUSTED_PROXY_CIDRS = "198.51.100.7/32";
|
|
expect(
|
|
getClientIp(
|
|
stamped("198.51.100.7", { "x-forwarded-for": "203.0.113.9" }),
|
|
),
|
|
).toBe("203.0.113.9");
|
|
});
|
|
|
|
it("takes IPv6 ranges and bare addresses", () => {
|
|
process.env.TRUSTED_PROXY_CIDRS = "fd00::/8,2001:db8::1";
|
|
expect(
|
|
getClientIp(stamped("fd12::3", { "x-forwarded-for": "203.0.113.1" })),
|
|
).toBe("203.0.113.1");
|
|
expect(
|
|
getClientIp(stamped("2001:db8::1", { "x-forwarded-for": "203.0.113.2" })),
|
|
).toBe("203.0.113.2");
|
|
expect(
|
|
getClientIp(stamped("2001:db8::2", { "x-forwarded-for": "203.0.113.3" })),
|
|
).toBe("2001:db8::2");
|
|
});
|
|
|
|
it("lets no peer forward when an entry is not a CIDR", () => {
|
|
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16, not-a-range";
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
expect(
|
|
getClientIp(stamped("172.18.0.5", { "x-forwarded-for": "203.0.113.5" })),
|
|
).toBe("172.18.0.5");
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe("the boot line naming the proxy rule", () => {
|
|
it("names the private-range default, and what it lets in behind a port forwarder", () => {
|
|
expect(describeProxyTrust()).toMatchObject({
|
|
level: "info",
|
|
message: expect.stringMatching(/TRUSTED_PROXY_CIDRS unset.*docker-proxy/),
|
|
});
|
|
});
|
|
|
|
it("names the list when one is set", () => {
|
|
process.env.TRUSTED_PROXY_CIDRS = "127.0.0.1/32";
|
|
expect(describeProxyTrust()).toMatchObject({
|
|
level: "info",
|
|
message: expect.stringContaining("only connections from 127.0.0.1/32"),
|
|
});
|
|
});
|
|
|
|
it("names a bad TRUST_PROXY_HOPS and a bad CIDR as errors", () => {
|
|
process.env.TRUST_PROXY_HOPS = "one";
|
|
process.env.TRUSTED_PROXY_CIDRS = "10.0.0.0/33";
|
|
const line = describeProxyTrust();
|
|
expect(line.level).toBe("error");
|
|
expect(line.message).toContain('TRUST_PROXY_HOPS="one"');
|
|
expect(line.message).toContain('"10.0.0.0/33"');
|
|
});
|
|
});
|