From 09576231c913db79f6b39d5b16900ed0558eb4af Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 13 May 2026 23:25:49 -0500 Subject: [PATCH] allow domains to get ips of users instead of docker 172 --- .env.example | 12 ++ Overlord-Server/src/server/client-ip.test.ts | 136 +++++++++++++++++++ Overlord-Server/src/server/client-ip.ts | 135 ++++++++++++++++++ Overlord-Server/src/server/http-dispatch.ts | 5 +- docker-compose.quickstart.yml | 6 + docker-compose.windows.yml | 6 + docker-compose.yml | 6 + 7 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 Overlord-Server/src/server/client-ip.test.ts create mode 100644 Overlord-Server/src/server/client-ip.ts diff --git a/.env.example b/.env.example index 1259043..f8dadb4 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,18 @@ # OVERLORD_TLS_CERT=/custom/path/server.crt # OVERLORD_TLS_KEY=/custom/path/server.key +# ============================================ +# ADVANCED: Reverse proxy / domain deployments +# ============================================ +# If Overlord sits behind a reverse proxy (nginx, Caddy, Cloudflare, Traefik, Render, +# etc.), the server otherwise sees the proxy's IP for every agent connection — the +# dashboard, audit log, and IP bans all end up showing the proxy/Docker bridge IP +# (commonly 172.x.x.x) instead of the real client. Set this to true ONLY when a +# trusted reverse proxy is in front of Overlord; otherwise agents could spoof +# their IP by sending their own X-Forwarded-For header. Auto-enabled when +# OVERLORD_TLS_OFFLOAD=true. Honors X-Forwarded-For, X-Real-IP, CF-Connecting-IP. +# OVERLORD_TRUST_PROXY=true + # Agent builder settings (only needed if building agents) # TARGETS=windows/amd64,windows/386,windows/arm64,linux/amd64,linux/arm64,linux/arm/v7,darwin/amd64,darwin/arm64 # GO_BUILD_FLAGS=-trimpath diff --git a/Overlord-Server/src/server/client-ip.test.ts b/Overlord-Server/src/server/client-ip.test.ts new file mode 100644 index 0000000..b969941 --- /dev/null +++ b/Overlord-Server/src/server/client-ip.test.ts @@ -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 = {}; + +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): 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"); + }); +}); diff --git a/Overlord-Server/src/server/client-ip.ts b/Overlord-Server/src/server/client-ip.ts new file mode 100644 index 0000000..8a1d9d0 --- /dev/null +++ b/Overlord-Server/src/server/client-ip.ts @@ -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(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; +} diff --git a/Overlord-Server/src/server/http-dispatch.ts b/Overlord-Server/src/server/http-dispatch.ts index 5c3f4f8..5263562 100644 --- a/Overlord-Server/src/server/http-dispatch.ts +++ b/Overlord-Server/src/server/http-dispatch.ts @@ -1,3 +1,5 @@ +import { wrapServerWithClientIp, type RequestServerLike } from "./client-ip"; + export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise; 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 }); diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index a45db05..c162324 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -30,6 +30,12 @@ services: # Set true only behind a trusted reverse proxy that terminates TLS (for example Render). # Default false keeps current behavior: Overlord terminates TLS in-process. - OVERLORD_TLS_OFFLOAD=false + # Trust X-Forwarded-For / X-Real-IP / CF-Connecting-IP from an upstream proxy when + # resolving the agent's source IP shown in the dashboard, audit log, and IP bans. + # Set true only when a trusted reverse proxy (nginx, Caddy, Cloudflare, etc.) sits + # in front of Overlord — otherwise agents can spoof their IP. Auto-enabled when + # OVERLORD_TLS_OFFLOAD=true. + - OVERLORD_TRUST_PROXY=false # Cookie security mode: auto (default), true, or false. # auto: Secure cookie on HTTPS requests / X-Forwarded-Proto=https. - OVERLORD_AUTH_COOKIE_SECURE=auto diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 684cd55..c7d150d 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -28,6 +28,12 @@ services: # Set true only behind a trusted reverse proxy that terminates TLS (for example Render). # Default false keeps current behavior: Overlord terminates TLS in-process. OVERLORD_TLS_OFFLOAD: ${OVERLORD_TLS_OFFLOAD:-false} + # Trust X-Forwarded-For / X-Real-IP / CF-Connecting-IP from an upstream proxy when + # resolving the agent's source IP shown in the dashboard, audit log, and IP bans. + # Set true only when a trusted reverse proxy (nginx, Caddy, Cloudflare, etc.) sits + # in front of Overlord — otherwise agents can spoof their IP. Auto-enabled when + # OVERLORD_TLS_OFFLOAD=true. + OVERLORD_TRUST_PROXY: ${OVERLORD_TRUST_PROXY:-false} # Cookie security mode: auto (default), true, or false. # auto: Secure cookie on HTTPS requests / X-Forwarded-Proto=https. OVERLORD_AUTH_COOKIE_SECURE: ${OVERLORD_AUTH_COOKIE_SECURE:-auto} diff --git a/docker-compose.yml b/docker-compose.yml index d5b24e1..9956a29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,12 @@ services: # Set true only behind a trusted reverse proxy that terminates TLS (for example Render). # Default false keeps current behavior: Overlord terminates TLS in-process. OVERLORD_TLS_OFFLOAD: ${OVERLORD_TLS_OFFLOAD:-false} + # Trust X-Forwarded-For / X-Real-IP / CF-Connecting-IP from an upstream proxy when + # resolving the agent's source IP shown in the dashboard, audit log, and IP bans. + # Set true only when a trusted reverse proxy (nginx, Caddy, Cloudflare, etc.) sits + # in front of Overlord — otherwise agents can spoof their IP. Auto-enabled when + # OVERLORD_TLS_OFFLOAD=true. + OVERLORD_TRUST_PROXY: ${OVERLORD_TRUST_PROXY:-false} # Cookie security mode: auto (default), true, or false. # auto: Secure cookie on HTTPS requests / X-Forwarded-Proto=https. OVERLORD_AUTH_COOKIE_SECURE: ${OVERLORD_AUTH_COOKIE_SECURE:-auto}