import { describe, expect, it } from "@effect/vitest";import { Option } from "effect"; import { CLIENT_IP_HEADER, PROXY_HINT_HEADERS, clientIpAddressOptions, makeClientIpStamper, parseIpRange,} from "./client-ip"; const signIn = (headers: Record<string, string> = {}) => new Request("https://host.example/api/auth/sign-in/email", { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify({ email: "a@example.com", password: "pw" }), }); describe("parseIpRange", () => { it("accepts bare IPv4/IPv6 addresses as single-host ranges", () => { expect(parseIpRange("192.0.2.10")).toEqual({ address: "192.0.2.10", prefix: 32, family: "ipv4", }); expect(parseIpRange("2001:db8::1")).toEqual({ address: "2001:db8::1", prefix: 128, family: "ipv6", }); }); it("accepts CIDR ranges within the family's prefix length", () => { expect(parseIpRange("10.0.0.0/8")).toEqual({ address: "10.0.0.0", prefix: 8, family: "ipv4" }); expect(parseIpRange("2001:db8::/32")).toEqual({ address: "2001:db8::", prefix: 32, family: "ipv6", }); }); it.each(["", "proxy", "10.0.0/8", "10.0.0.0/33", "10.0.0.0/-1", "10.0.0.0/8/8", "::/129"])( "rejects %j", (raw) => { expect(parseIpRange(raw)).toBeUndefined(); }, );}); describe("clientIpAddressOptions", () => { it("reads only the stamped header, with no trusted proxies, when no proxy is configured", () => { expect(clientIpAddressOptions(undefined)).toStrictEqual({ ipAddressHeaders: [CLIENT_IP_HEADER], }); }); it("reads the proxy header first, then the stamped header, and trusts the proxy ranges", () => { expect( clientIpAddressOptions({ header: "cf-connecting-ip", proxies: ["10.0.0.0/8", "192.0.2.10"] }), ).toStrictEqual({ ipAddressHeaders: ["cf-connecting-ip", CLIENT_IP_HEADER], trustedProxies: ["10.0.0.0/8", "192.0.2.10"], }); });}); describe("makeClientIpStamper without a trusted proxy", () => { // The warning path has its own suite below; keep it out of this one's output. const stamp = makeClientIpStamper(undefined, { warn: () => {} }); it("overwrites a client-supplied header with the socket peer address", async () => { const out = stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.some("198.51.100.4")); expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4"); }); it("gives two socket peers two different stamps", () => { const a = stamp(signIn(), Option.some("198.51.100.4")); const b = stamp(signIn(), Option.some("198.51.100.5")); expect(a.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4"); expect(b.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.5"); }); it("removes a client-supplied header when the runtime reports no peer", () => { const out = stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.none()); expect(out.headers.has(CLIENT_IP_HEADER)).toBe(false); }); it("preserves method, URL, other headers, and body", async () => { const out = stamp(signIn({ "x-forwarded-for": "203.0.113.9" }), Option.some("198.51.100.4")); expect(out.method).toBe("POST"); expect(out.url).toBe("https://host.example/api/auth/sign-in/email"); expect(out.headers.get("content-type")).toBe("application/json"); expect(out.headers.get("x-forwarded-for")).toBe("203.0.113.9"); expect(await out.json()).toEqual({ email: "a@example.com", password: "pw" }); }); // The Vite dev middleware builds the request over a stream the runtime did // not create itself (`Readable.toWeb(req)`); the stamped request must still // deliver it in full. it("passes a streamed body through", async () => { const payload = JSON.stringify({ email: "a@example.com", password: "pw" }); const init: RequestInit & { duplex?: "half" } = { method: "POST", headers: { "content-type": "application/json" }, body: new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(payload)); controller.close(); }, }), duplex: "half", }; const streamed = new Request("https://host.example/api/auth/sign-in/email", init); const out = stamp(streamed, Option.some("198.51.100.4")); expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4"); expect(await out.text()).toBe(payload); }); it("stamps a bodiless request without inventing a body", () => { const out = stamp( new Request("https://host.example/api/auth/get-session", { method: "GET" }), Option.some("198.51.100.4"), ); expect(out.method).toBe("GET"); expect(out.body).toBeNull(); expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4"); });}); describe("makeClientIpStamper behind a trusted proxy", () => { const stamp = makeClientIpStamper({ header: "x-real-ip", proxies: ["10.0.0.0/8", "2001:db8::/32"], }); it("keeps the proxy header on a connection from a trusted proxy", () => { const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.some("10.1.2.3")); expect(out.headers.get("x-real-ip")).toBe("203.0.113.9"); expect(out.headers.get(CLIENT_IP_HEADER)).toBe("10.1.2.3"); }); it("matches an IPv4 proxy reported as an IPv4-mapped IPv6 peer", () => { const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.some("::ffff:10.1.2.3")); expect(out.headers.get("x-real-ip")).toBe("203.0.113.9"); }); it("matches an IPv6 proxy range", () => { const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.some("2001:db8:1::7")); expect(out.headers.get("x-real-ip")).toBe("203.0.113.9"); }); it("strips the proxy header on a direct connection from an untrusted peer", () => { const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.some("198.51.100.4")); expect(out.headers.has("x-real-ip")).toBe(false); expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4"); }); it("strips the proxy header when the runtime reports no peer", () => { const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.none()); expect(out.headers.has("x-real-ip")).toBe(false); expect(out.headers.has(CLIENT_IP_HEADER)).toBe(false); });}); describe("unconfigured-proxy warning", () => { const stamperWithWarnings = (trustedProxy?: { header: string; proxies: string[] }) => { const warnings: string[] = []; const stamp = makeClientIpStamper(trustedProxy, { warn: (message) => { warnings.push(message); }, }); return { stamp, warnings }; }; it.each(PROXY_HINT_HEADERS)( "fires once for %s with no trusted proxy configured, naming both variables", (header) => { const { stamp, warnings } = stamperWithWarnings(); stamp(signIn({ [header]: "203.0.113.9" }), Option.some("172.18.0.2")); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain(header); expect(warnings[0]).toContain("EXECUTOR_TRUSTED_PROXY_HEADER"); expect(warnings[0]).toContain("EXECUTOR_TRUSTED_PROXIES"); stamp(signIn({ [header]: "203.0.113.9" }), Option.some("172.18.0.2")); stamp(signIn({ "x-forwarded-for": "203.0.113.10" }), Option.some("172.18.0.2")); expect(warnings).toHaveLength(1); }, ); it("does not fire for a request without a proxy-style header", () => { const { stamp, warnings } = stamperWithWarnings(); stamp(signIn(), Option.some("198.51.100.4")); stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.some("198.51.100.4")); expect(warnings).toHaveLength(0); }); it("does not fire when a trusted proxy is configured", () => { const { stamp, warnings } = stamperWithWarnings({ header: "x-real-ip", proxies: ["172.18.0.2"], }); // From the proxy, with the configured header plus the extras nginx sends. stamp( signIn({ "x-real-ip": "203.0.113.9", "x-forwarded-for": "203.0.113.9" }), Option.some("172.18.0.2"), ); // Direct from an untrusted peer asserting a proxy header. stamp(signIn({ "x-forwarded-for": "203.0.113.9" }), Option.some("198.51.100.4")); expect(warnings).toHaveLength(0); });});
import { describe, expect, it } from "@effect/vitest";
import { Option } from "effect";
import {
CLIENT_IP_HEADER,
PROXY_HINT_HEADERS,
clientIpAddressOptions,
makeClientIpStamper,
parseIpRange,
} from "./client-ip";
const signIn = (headers: Record<string, string> = {}) =>
new Request("https://host.example/api/auth/sign-in/email", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify({ email: "a@example.com", password: "pw" }),
});
describe("parseIpRange", () => {
it("accepts bare IPv4/IPv6 addresses as single-host ranges", () => {
expect(parseIpRange("192.0.2.10")).toEqual({
address: "192.0.2.10",
prefix: 32,
family: "ipv4",
});
expect(parseIpRange("2001:db8::1")).toEqual({
address: "2001:db8::1",
prefix: 128,
family: "ipv6",
});
});
it("accepts CIDR ranges within the family's prefix length", () => {
expect(parseIpRange("10.0.0.0/8")).toEqual({ address: "10.0.0.0", prefix: 8, family: "ipv4" });
expect(parseIpRange("2001:db8::/32")).toEqual({
address: "2001:db8::",
prefix: 32,
family: "ipv6",
});
});
it.each(["", "proxy", "10.0.0/8", "10.0.0.0/33", "10.0.0.0/-1", "10.0.0.0/8/8", "::/129"])(
"rejects %j",
(raw) => {
expect(parseIpRange(raw)).toBeUndefined();
},
);
});
describe("clientIpAddressOptions", () => {
it("reads only the stamped header, with no trusted proxies, when no proxy is configured", () => {
expect(clientIpAddressOptions(undefined)).toStrictEqual({
ipAddressHeaders: [CLIENT_IP_HEADER],
});
});
it("reads the proxy header first, then the stamped header, and trusts the proxy ranges", () => {
expect(
clientIpAddressOptions({ header: "cf-connecting-ip", proxies: ["10.0.0.0/8", "192.0.2.10"] }),
).toStrictEqual({
ipAddressHeaders: ["cf-connecting-ip", CLIENT_IP_HEADER],
trustedProxies: ["10.0.0.0/8", "192.0.2.10"],
});
});
});
describe("makeClientIpStamper without a trusted proxy", () => {
// The warning path has its own suite below; keep it out of this one's output.
const stamp = makeClientIpStamper(undefined, { warn: () => {} });
it("overwrites a client-supplied header with the socket peer address", async () => {
const out = stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.some("198.51.100.4"));
expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4");
});
it("gives two socket peers two different stamps", () => {
const a = stamp(signIn(), Option.some("198.51.100.4"));
const b = stamp(signIn(), Option.some("198.51.100.5"));
expect(a.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4");
expect(b.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.5");
});
it("removes a client-supplied header when the runtime reports no peer", () => {
const out = stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.none());
expect(out.headers.has(CLIENT_IP_HEADER)).toBe(false);
});
it("preserves method, URL, other headers, and body", async () => {
const out = stamp(signIn({ "x-forwarded-for": "203.0.113.9" }), Option.some("198.51.100.4"));
expect(out.method).toBe("POST");
expect(out.url).toBe("https://host.example/api/auth/sign-in/email");
expect(out.headers.get("content-type")).toBe("application/json");
expect(out.headers.get("x-forwarded-for")).toBe("203.0.113.9");
expect(await out.json()).toEqual({ email: "a@example.com", password: "pw" });
});
// The Vite dev middleware builds the request over a stream the runtime did
// not create itself (`Readable.toWeb(req)`); the stamped request must still
// deliver it in full.
it("passes a streamed body through", async () => {
const payload = JSON.stringify({ email: "a@example.com", password: "pw" });
const init: RequestInit & { duplex?: "half" } = {
method: "POST",
headers: { "content-type": "application/json" },
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(payload));
controller.close();
},
}),
duplex: "half",
};
const streamed = new Request("https://host.example/api/auth/sign-in/email", init);
const out = stamp(streamed, Option.some("198.51.100.4"));
expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4");
expect(await out.text()).toBe(payload);
});
it("stamps a bodiless request without inventing a body", () => {
const out = stamp(
new Request("https://host.example/api/auth/get-session", { method: "GET" }),
Option.some("198.51.100.4"),
);
expect(out.method).toBe("GET");
expect(out.body).toBeNull();
expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4");
});
});
describe("makeClientIpStamper behind a trusted proxy", () => {
const stamp = makeClientIpStamper({
header: "x-real-ip",
proxies: ["10.0.0.0/8", "2001:db8::/32"],
});
it("keeps the proxy header on a connection from a trusted proxy", () => {
const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.some("10.1.2.3"));
expect(out.headers.get("x-real-ip")).toBe("203.0.113.9");
expect(out.headers.get(CLIENT_IP_HEADER)).toBe("10.1.2.3");
});
it("matches an IPv4 proxy reported as an IPv4-mapped IPv6 peer", () => {
const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.some("::ffff:10.1.2.3"));
expect(out.headers.get("x-real-ip")).toBe("203.0.113.9");
});
it("matches an IPv6 proxy range", () => {
const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.some("2001:db8:1::7"));
expect(out.headers.get("x-real-ip")).toBe("203.0.113.9");
});
it("strips the proxy header on a direct connection from an untrusted peer", () => {
const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.some("198.51.100.4"));
expect(out.headers.has("x-real-ip")).toBe(false);
expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4");
});
it("strips the proxy header when the runtime reports no peer", () => {
const out = stamp(signIn({ "x-real-ip": "203.0.113.9" }), Option.none());
expect(out.headers.has("x-real-ip")).toBe(false);
expect(out.headers.has(CLIENT_IP_HEADER)).toBe(false);
});
});
describe("unconfigured-proxy warning", () => {
const stamperWithWarnings = (trustedProxy?: { header: string; proxies: string[] }) => {
const warnings: string[] = [];
const stamp = makeClientIpStamper(trustedProxy, {
warn: (message) => {
warnings.push(message);
},
});
return { stamp, warnings };
};
it.each(PROXY_HINT_HEADERS)(
"fires once for %s with no trusted proxy configured, naming both variables",
(header) => {
const { stamp, warnings } = stamperWithWarnings();
stamp(signIn({ [header]: "203.0.113.9" }), Option.some("172.18.0.2"));
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain(header);
expect(warnings[0]).toContain("EXECUTOR_TRUSTED_PROXY_HEADER");
expect(warnings[0]).toContain("EXECUTOR_TRUSTED_PROXIES");
stamp(signIn({ [header]: "203.0.113.9" }), Option.some("172.18.0.2"));
stamp(signIn({ "x-forwarded-for": "203.0.113.10" }), Option.some("172.18.0.2"));
expect(warnings).toHaveLength(1);
},
);
it("does not fire for a request without a proxy-style header", () => {
const { stamp, warnings } = stamperWithWarnings();
stamp(signIn(), Option.some("198.51.100.4"));
stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.some("198.51.100.4"));
expect(warnings).toHaveLength(0);
});
it("does not fire when a trusted proxy is configured", () => {
const { stamp, warnings } = stamperWithWarnings({
header: "x-real-ip",
proxies: ["172.18.0.2"],
});
// From the proxy, with the configured header plus the extras nginx sends.
stamp(
signIn({ "x-real-ip": "203.0.113.9", "x-forwarded-for": "203.0.113.9" }),
Option.some("172.18.0.2"),
);
// Direct from an untrusted peer asserting a proxy header.
stamp(signIn({ "x-forwarded-for": "203.0.113.9" }), Option.some("198.51.100.4"));
expect(warnings).toHaveLength(0);
});
});