allow domains to get ips of users instead of docker 172
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user