Files
overlord/Overlord-Server/src/server/client-ip.ts
T

128 lines
3.9 KiB
TypeScript
Raw Normal View History

2026-08-24 09:35:03 -05:00
import { isIP } from "node:net";
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 {
2026-08-24 09:35:03 -05:00
return isIP(ip) === 4;
}
function isIPv6(ip: string): boolean {
2026-08-24 09:35:03 -05:00
return isIP(ip) === 6;
}
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()));
}
2026-08-24 09:35:03 -05:00
function isValidIp(ip: string): boolean {
return isIPv4(ip) || isIPv6(ip);
}
function isLoopbackIp(ip: string): boolean {
if (isIPv4(ip)) return Number(ip.split(".")[0]) === 127;
if (!isIPv6(ip)) return false;
const lower = ip.toLowerCase();
if (lower === "::1") return true;
const dottedMapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (dottedMapped) return Number(dottedMapped[1].split(".")[0]) === 127;
const hexMapped = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
return !!hexMapped && (Number.parseInt(hexMapped[1], 16) >> 8) === 127;
}
function isTrustedImmediateProxy(peer: string): boolean {
const normalized = normalizeCandidate(peer);
if (!isValidIp(normalized)) return false;
if (isLoopbackIp(normalized)) return true;
const configured = String(process.env.OVERLORD_TRUSTED_PROXY_IPS || "")
.split(",")
.map(normalizeCandidate)
.filter(Boolean);
return configured.includes(normalized);
}
export function resolveForwardedIp(req: Request, fallback: string): string {
if (!isTrustProxyEnabled()) return fallback;
2026-08-24 09:35:03 -05:00
if (!isTrustedImmediateProxy(fallback)) return fallback;
const xff = req.headers.get("x-forwarded-for");
if (xff) {
const parts = xff
.split(",")
.map((s) => normalizeCandidate(s))
2026-08-24 09:35:03 -05:00
.filter(isValidIp);
// A conforming edge proxy appends the actual peer address. Selecting from
// the right prevents an attacker from choosing a spoofed leftmost value.
if (parts.length > 0) return parts[parts.length - 1];
}
const xri = req.headers.get("x-real-ip");
if (xri) {
const normalized = normalizeCandidate(xri);
2026-08-24 09:35:03 -05:00
if (isValidIp(normalized)) return normalized;
}
const cfConnecting = req.headers.get("cf-connecting-ip");
if (cfConnecting) {
const normalized = normalizeCandidate(cfConnecting);
2026-08-24 09:35:03 -05:00
if (isValidIp(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;
}