allow domains to get ips of users instead of docker 172

This commit is contained in:
KingKDot
2026-05-13 23:25:49 -05:00
parent 4fae9e56cf
commit 09576231c9
7 changed files with 305 additions and 1 deletions
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
isTrustProxyEnabled,
resetTrustProxyCacheForTests,
resolveForwardedIp,
wrapServerWithClientIp,
} from "./client-ip";
const ENV_KEYS = ["OVERLORD_TRUST_PROXY", "OVERLORD_TLS_OFFLOAD"] as const;
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const k of ENV_KEYS) saved[k] = process.env[k];
for (const k of ENV_KEYS) delete process.env[k];
resetTrustProxyCacheForTests();
});
afterEach(() => {
for (const k of ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
resetTrustProxyCacheForTests();
});
function makeReq(headers: Record<string, string>): Request {
return new Request("https://example.com/", { headers });
}
describe("isTrustProxyEnabled", () => {
test("off by default", () => {
expect(isTrustProxyEnabled()).toBe(false);
});
test("on when OVERLORD_TRUST_PROXY=true", () => {
process.env.OVERLORD_TRUST_PROXY = "true";
resetTrustProxyCacheForTests();
expect(isTrustProxyEnabled()).toBe(true);
});
test("on when OVERLORD_TLS_OFFLOAD=true (implies trusted proxy)", () => {
process.env.OVERLORD_TLS_OFFLOAD = "true";
resetTrustProxyCacheForTests();
expect(isTrustProxyEnabled()).toBe(true);
});
});
describe("resolveForwardedIp - trust disabled", () => {
test("returns peer when trust is off, ignoring X-Forwarded-For", () => {
const req = makeReq({ "x-forwarded-for": "1.2.3.4" });
expect(resolveForwardedIp(req, "172.18.0.1")).toBe("172.18.0.1");
});
});
describe("resolveForwardedIp - trust enabled", () => {
beforeEach(() => {
process.env.OVERLORD_TRUST_PROXY = "true";
resetTrustProxyCacheForTests();
});
test("picks leftmost public IP from X-Forwarded-For", () => {
const req = makeReq({ "x-forwarded-for": "203.0.113.42, 10.0.0.1, 172.17.0.1" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("203.0.113.42");
});
test("skips private hops and picks the public client", () => {
const req = makeReq({ "x-forwarded-for": "10.0.0.5, 203.0.113.99" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("203.0.113.99");
});
test("falls back to leftmost when all hops are private", () => {
const req = makeReq({ "x-forwarded-for": "10.0.0.1, 172.17.0.1" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("10.0.0.1");
});
test("honors X-Real-IP when no X-Forwarded-For", () => {
const req = makeReq({ "x-real-ip": "198.51.100.7" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("198.51.100.7");
});
test("honors CF-Connecting-IP as last resort", () => {
const req = makeReq({ "cf-connecting-ip": "198.51.100.8" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("198.51.100.8");
});
test("falls back to peer when no forwarded headers present", () => {
const req = makeReq({});
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("172.17.0.1");
});
test("strips ports and IPv6 brackets", () => {
const req = makeReq({ "x-forwarded-for": "203.0.113.42:51234" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("203.0.113.42");
const req2 = makeReq({ "x-forwarded-for": "[2001:db8::1]:443" });
expect(resolveForwardedIp(req2, "172.17.0.1")).toBe("2001:db8::1");
});
test("classifies 172.17.x as private (Docker bridge)", () => {
const req = makeReq({ "x-forwarded-for": "172.17.0.5, 203.0.113.10" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("203.0.113.10");
});
});
describe("wrapServerWithClientIp", () => {
test("requestIP returns forwarded address when trust enabled", () => {
process.env.OVERLORD_TRUST_PROXY = "true";
resetTrustProxyCacheForTests();
let upgradeCalled = false;
const fakeServer = {
requestIP: (_req: Request) => ({ address: "172.18.0.1" }),
upgrade: (_req: Request, _data: any) => {
upgradeCalled = true;
return true;
},
};
const wrapped = wrapServerWithClientIp(fakeServer);
const req = makeReq({ "x-forwarded-for": "203.0.113.55" });
expect(wrapped.requestIP(req)?.address).toBe("203.0.113.55");
wrapped.upgrade(req, { foo: 1 });
expect(upgradeCalled).toBe(true);
});
test("requestIP returns peer when trust disabled", () => {
const fakeServer = {
requestIP: (_req: Request) => ({ address: "172.18.0.1" }),
upgrade: (_req: Request, _data: any) => true,
};
const wrapped = wrapServerWithClientIp(fakeServer);
const req = makeReq({ "x-forwarded-for": "203.0.113.55" });
expect(wrapped.requestIP(req)?.address).toBe("172.18.0.1");
});
});
+135
View File
@@ -0,0 +1,135 @@
function envFlagEnabled(name: string): boolean {
const v = String(process.env[name] || "").trim().toLowerCase();
return v === "1" || v === "true" || v === "yes" || v === "on";
}
let trustProxyCache: boolean | null = null;
export function isTrustProxyEnabled(): boolean {
if (trustProxyCache !== null) return trustProxyCache;
trustProxyCache =
envFlagEnabled("OVERLORD_TRUST_PROXY") || envFlagEnabled("OVERLORD_TLS_OFFLOAD");
return trustProxyCache;
}
export function resetTrustProxyCacheForTests(): void {
trustProxyCache = null;
}
function isIPv4(ip: string): boolean {
const parts = ip.split(".");
if (parts.length !== 4) return false;
for (const p of parts) {
if (!/^\d{1,3}$/.test(p)) return false;
const n = Number(p);
if (n < 0 || n > 255) return false;
}
return true;
}
function isIPv6(ip: string): boolean {
return ip.includes(":") && /^[0-9a-fA-F:.]+$/.test(ip);
}
function isPrivateIPv4(ip: string): boolean {
const [a, b] = ip.split(".").map(Number);
if (a === 10) return true;
if (a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
return false;
}
function isPrivateIPv6(ip: string): boolean {
const lower = ip.toLowerCase();
if (lower === "::1") return true;
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
if (lower.startsWith("fe80:")) return true;
if (lower.startsWith("::ffff:")) {
const v4 = lower.slice("::ffff:".length);
return isIPv4(v4) && isPrivateIPv4(v4);
}
return false;
}
function stripIPv6Brackets(ip: string): string {
if (ip.startsWith("[") && ip.endsWith("]")) return ip.slice(1, -1);
return ip;
}
function stripPort(ip: string): string {
if (ip.startsWith("[")) {
const close = ip.indexOf("]");
if (close !== -1) return ip.slice(1, close);
}
if (isIPv4(ip)) return ip;
const colons = (ip.match(/:/g) || []).length;
if (colons === 1) return ip.split(":")[0];
return ip;
}
function normalizeCandidate(raw: string): string {
return stripIPv6Brackets(stripPort(raw.trim()));
}
function isPrivateOrInvalid(ip: string): boolean {
if (!ip) return true;
if (isIPv4(ip)) return isPrivateIPv4(ip);
if (isIPv6(ip)) return isPrivateIPv6(ip);
return true;
}
export function resolveForwardedIp(req: Request, fallback: string): string {
if (!isTrustProxyEnabled()) return fallback;
const xff = req.headers.get("x-forwarded-for");
if (xff) {
const parts = xff
.split(",")
.map((s) => normalizeCandidate(s))
.filter(Boolean);
const publicHop = parts.find((p) => !isPrivateOrInvalid(p));
if (publicHop) return publicHop;
if (parts.length > 0) return parts[0];
}
const xri = req.headers.get("x-real-ip");
if (xri) {
const normalized = normalizeCandidate(xri);
if (normalized) return normalized;
}
const cfConnecting = req.headers.get("cf-connecting-ip");
if (cfConnecting) {
const normalized = normalizeCandidate(cfConnecting);
if (normalized) return normalized;
}
return fallback;
}
export type RequestServerLike = {
requestIP: (req: Request) => { address?: string } | null | undefined;
upgrade: (req: Request, data: any) => boolean;
};
export function wrapServerWithClientIp<T extends RequestServerLike>(server: T): T {
const wrapped = {
requestIP: (req: Request) => {
const peer = server.requestIP(req)?.address || "";
const real = resolveForwardedIp(req, peer);
return { address: real };
},
upgrade: (req: Request, data: any) => server.upgrade(req, data),
};
return new Proxy(server as object, {
get(target, prop, receiver) {
if (prop === "requestIP") return wrapped.requestIP;
if (prop === "upgrade") return wrapped.upgrade;
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
}) as T;
}
+4 -1
View File
@@ -1,3 +1,5 @@
import { wrapServerWithClientIp, type RequestServerLike } from "./client-ip";
export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise<Response | null>;
export function createHttpFetchHandler(deps: {
@@ -11,8 +13,9 @@ export function createHttpFetchHandler(deps: {
return new Response("", { headers: deps.CORS_HEADERS });
}
const url = new URL(req.url);
const wrapped = wrapServerWithClientIp(server as RequestServerLike);
for (const route of deps.routes) {
const response = await route(req, url, server);
const response = await route(req, url, wrapped);
if (response) return response;
}
return new Response("Not found", { status: 404 });