diff --git a/.changeset/selfhost-auth-client-ip.md b/.changeset/selfhost-auth-client-ip.md new file mode 100644 index 0000000000..0af44596e5 --- /dev/null +++ b/.changeset/selfhost-auth-client-ip.md @@ -0,0 +1,8 @@ +--- +"@executor-js/host-selfhost": patch +"executor": patch +--- + +Key the self-host's sign-in rate limit on the real client IP. The server now stamps the connecting address on every auth request, so a directly exposed instance limits each client separately with no configuration and a client cannot spoof its address. + +Behaviour change for proxied deployments: `x-forwarded-for` is no longer read on its own, because any client could set it. If Executor runs behind a reverse proxy, set both `EXECUTOR_TRUSTED_PROXY_HEADER` (the header the proxy sets, e.g. `cf-connecting-ip` or `x-real-ip`) and `EXECUTOR_TRUSTED_PROXIES` (the proxy's own IPs or CIDR ranges), otherwise all users share one sign-in bucket. The server logs one warning naming both variables when an auth request carries a proxy header and neither is set. List only the proxy addresses, never a range that also contains your users; if every hop is trusted no client IP is found and the bucket is shared again. Half-configured or malformed values refuse to boot. diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 807951b07f..bd1c584b17 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -53,23 +53,25 @@ Back it up by snapshotting that volume (or copying `/data`, primarily `data.db`) Everything is optional: a bare run boots a working instance. The defaults below are the container defaults. -| Variable | Default | Purpose | -| ----------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | -| `PORT` | `4788` | HTTP port the server listens on. | -| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. | -| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | -| `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | -| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | -| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. | -| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | -| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | -| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | -| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. | -| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. | -| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | -| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | -| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | -| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. | +| Variable | Default | Purpose | +| ----------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- | +| `PORT` | `4788` | HTTP port the server listens on. | +| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. | +| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | +| `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | +| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | +| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. | +| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | +| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | +| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | +| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. | +| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. | +| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | +| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | +| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | +| `EXECUTOR_TRUSTED_PROXY_HEADER` | unset | Header your reverse proxy sets to the real client IP, e.g. `cf-connecting-ip`. Set with the next. | +| `EXECUTOR_TRUSTED_PROXIES` | unset | Comma-separated IPs or CIDR ranges the proxy connects from. Set with the previous. | +| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. | Tracing is configured separately, and off unless you turn it on — see [Tracing](/hosted/tracing). @@ -109,6 +111,46 @@ Only cookie-authenticated browser requests use this allowlist. OAuth callbacks, MCP metadata, approval links, and other absolute URLs remain pinned to `EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers. +### Behind a reverse proxy + +Sign-in attempts are rate-limited per client IP (three per ten seconds). When +browsers reach the container directly, the IP is the connecting address and +nothing needs configuring. + +Behind Caddy, nginx, Cloudflare, or another reverse proxy, every connection +comes from the proxy. If Executor runs behind a reverse proxy, set both +variables, otherwise all users share one sign-in bucket. Tell Executor which +header the proxy sets to the real client IP, and which addresses the proxy +connects from: + +```bash +-e EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip \ +-e EXECUTOR_TRUSTED_PROXIES=172.18.0.2 +``` + +Set both together. The header is only honoured on connections from one of the +listed addresses, so a client that reaches the container directly cannot spoof +it. List only the proxy's own addresses (its IP, or the compose network it +shares with Executor, such as `172.18.0.0/24`), never a range that also +contains your users: every listed address counts as a proxy hop, and if every +hop is trusted Better Auth finds no client and falls back to one shared bucket. +Use `cf-connecting-ip` with Cloudflare's published IP ranges, `x-real-ip` for +nginx (`proxy_set_header X-Real-IP $remote_addr;`), or `x-forwarded-for` for +Caddy. A malformed entry refuses to boot rather than silently pooling every +user into one bucket. + +Executor never trusts `x-forwarded-for` or a similar header on its own, because +any client could set it. When an auth request carries one of those headers and +no trusted proxy is configured, the server logs one warning naming these two +variables. + +On Linux, Docker's default iptables NAT preserves the client IP on a published +port, so a container reached directly needs nothing. Docker Desktop (macOS and +Windows) routes published ports through Docker's userland proxy, as does +loopback traffic from the Docker host itself, so every connection arrives from +one internal address; there, put a reverse proxy on the same Docker network in +front of Executor and configure it as above. + ## Connect an agent The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 3e3296c884..b7971eab36 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -37,9 +37,23 @@ # EXECUTOR_ALLOW_LOCAL_NETWORK=false # --- Auth rate limiting ------------------------------------------------------- -# Sign-in attempts are rate-limited per client IP. Without a trusted proxy -# header every caller shares one bucket. Set the exact string "true" only when -# something in front of Executor rate-limits instead. +# Sign-in attempts are rate-limited per client IP. When browsers reach Executor +# directly, the IP is the connecting address and nothing needs configuring. +# +# Behind a reverse proxy (Caddy, nginx, Cloudflare, ...) every connection comes +# from the proxy, so name the header the proxy sets to the real client IP and +# the addresses the proxy connects from. Set BOTH, otherwise all users share +# one sign-in bucket. The header is only honoured on connections from those +# addresses, so it cannot be spoofed by a client that reaches the container +# directly. List only the proxy's own addresses (its IP, or the compose network +# it shares with Executor, e.g. 172.18.0.0/24), never a range that also +# contains your users: if every hop is trusted no client IP is found and +# everyone shares one bucket again. +# EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip +# EXECUTOR_TRUSTED_PROXIES=172.18.0.2 +# +# Set the exact string "true" only when something in front of Executor +# rate-limits sign-ins instead. # EXECUTOR_DISABLE_AUTH_RATE_LIMIT=false # --- Local stdio MCP (trusted deployments only) ------------------------------- diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 18bcdf9fc2..734850a449 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -1,5 +1,5 @@ import { HttpApiSwagger } from "effect/unstable/httpapi"; -import { HttpEffect, HttpRouter } from "effect/unstable/http"; +import { HttpEffect, HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { Effect, Layer } from "effect"; import { @@ -12,6 +12,7 @@ import { import { runSqliteDataMigrations } from "@executor-js/sdk"; import { resolveAuthProviders } from "./auth"; +import { makeClientIpStamper } from "./auth/client-ip"; import { selfHostDataMigrations } from "./db/data-migrations"; import { makeSelfHostAdminApiLayer } from "./admin/handlers"; import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api"; @@ -76,6 +77,20 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { const { identityLayer, memberDirectoryLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); + // Better Auth keys its rate limiter on a header, never on the socket. Stamp + // the TCP peer address onto the web request right before Better Auth reads + // it (this is the one place that has both the Effect request, which knows + // the peer, and the web handler). Done here rather than in serve.ts's + // middleware because `fromWebHandler` hands Better Auth the original Bun + // `Request`, so Effect-level header rewrites never reach it. + const stampClientIp = makeClientIpStamper(config.trustedProxy); + const authRoute = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpEffect.fromWebHandler((web) => + authHandler(stampClientIp(web, request.remoteAddress)), + ); + }); + // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config); @@ -121,7 +136,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // (web/chromeless/device-page.tsx). HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler), // Better Auth owns the rest of /api/auth/*, the full path reaches it. - HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), + HttpRouter.add("*", "/api/auth/*", authRoute), // Browser approval of paused MCP executions: the console resume page // reads paused detail (GET) and records the decision (POST .../resume), // session-cookie-gated, delegating to the in-process MCP store. diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 4031fdf273..8587b0b8ad 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -14,6 +14,7 @@ import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; import { Context } from "effect"; import { loadConfig } from "../config"; +import { clientIpAddressOptions } from "./client-ip"; import { seedOrgAndAdmin } from "./seed"; import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites"; import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso"; @@ -129,7 +130,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: // pinned to config.webBaseUrl. baseURL: config.webBaseUrl, trustedOrigins: [...config.trustedOrigins], - advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, + advanced: { + useSecureCookies: !hasInsecureTrustedOrigin, + // Where the rate limiter (and the session's recorded address) reads the + // client IP. Better Auth only ever looks at headers, so app.ts stamps the + // socket peer onto CLIENT_IP_HEADER before the request gets here (see + // ./client-ip); a configured reverse proxy's header is consulted first. + // `trustedProxies` lets Better Auth strip known hops from a forwarded + // chain; the stamper has already dropped the proxy header on any + // connection that did not come from one of those addresses. + ipAddress: clientIpAddressOptions(config.trustedProxy), + }, // Better Auth's own limiter is on in production and off in development. // Only an explicit opt-out is passed through, so that environment default // stays in charge everywhere else. diff --git a/apps/host-selfhost/src/auth/client-ip.test.ts b/apps/host-selfhost/src/auth/client-ip.test.ts new file mode 100644 index 0000000000..6b2f64332b --- /dev/null +++ b/apps/host-selfhost/src/auth/client-ip.test.ts @@ -0,0 +1,213 @@ +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 = {}) => + 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({ + 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); + }); +}); diff --git a/apps/host-selfhost/src/auth/client-ip.ts b/apps/host-selfhost/src/auth/client-ip.ts new file mode 100644 index 0000000000..363d53548e --- /dev/null +++ b/apps/host-selfhost/src/auth/client-ip.ts @@ -0,0 +1,182 @@ +import { BlockList, isIP } from "node:net"; + +import { Option } from "effect"; + +import type { TrustedProxyConfig } from "../config"; + +// --------------------------------------------------------------------------- +// Client IP for Better Auth's rate limiter. +// +// Better Auth only reads the client IP from request headers. Nothing tells it +// which TCP peer the request came from, so on a directly exposed self-host it +// found no header, logged a warning, and pooled every caller into one bucket of +// three sign-ins per ten seconds. The fix has two halves: +// +// 1. The server stamps the socket peer address onto CLIENT_IP_HEADER before +// Better Auth sees the request, always overwriting anything the client +// sent. A direct client can only ever rate-limit itself. +// 2. Behind a reverse proxy the peer is the proxy, so the operator names the +// header the proxy sets (EXECUTOR_TRUSTED_PROXY_HEADER) and the addresses +// it connects from (EXECUTOR_TRUSTED_PROXIES). That header is honoured only +// on connections from one of those addresses and stripped otherwise, so a +// client that reaches the container directly cannot assert a proxy header. +// +// Better Auth's own default reads `x-forwarded-for` from anyone. That is not +// restored here: a client could rotate the header to dodge the limit. Behind +// an unconfigured proxy every user therefore shares one bucket, so the stamper +// warns the operator once when it sees a proxy-style header in that state. +// --------------------------------------------------------------------------- + +/** Server-stamped socket peer address. Never trusted from the client. */ +export const CLIENT_IP_HEADER = "x-executor-client-ip"; + +/** + * Headers a reverse proxy commonly sets to the client IP. Seeing one with no + * trusted proxy configured is the signature of a proxied deployment that has + * not told Executor about its proxy. + */ +export const PROXY_HINT_HEADERS = [ + "x-forwarded-for", + "x-real-ip", + "cf-connecting-ip", + "true-client-ip", +] as const; + +export interface IpRange { + readonly address: string; + readonly prefix: number; + readonly family: "ipv4" | "ipv6"; +} + +/** + * Parse an IP address or `address/prefix` CIDR range. `undefined` for anything + * else, so a typo cannot silently become a non-matching (or all-matching) rule. + */ +export const parseIpRange = (value: string): IpRange | undefined => { + const slash = value.indexOf("/"); + const address = slash === -1 ? value : value.slice(0, slash); + const version = isIP(address); + if (version === 0) return undefined; + const family = version === 6 ? "ipv6" : "ipv4"; + const maxPrefix = version === 6 ? 128 : 32; + if (slash === -1) return { address, prefix: maxPrefix, family }; + const prefixText = value.slice(slash + 1); + if (!/^\d{1,3}$/.test(prefixText)) return undefined; + const prefix = Number(prefixText); + if (prefix > maxPrefix) return undefined; + return { address, prefix, family }; +}; + +const blockListOf = (ranges: readonly IpRange[]): BlockList => { + const list = new BlockList(); + for (const range of ranges) list.addSubnet(range.address, range.prefix, range.family); + return list; +}; + +// `isIP` rejects the `::ffff:` prefix form only when malformed; a dual-stack +// socket may report an IPv4 peer as an IPv4-mapped IPv6 address, which +// BlockList matches against IPv4 rules on its own. +const isTrustedPeer = (list: BlockList, address: string): boolean => { + const version = isIP(address); + return version !== 0 && list.check(address, version === 6 ? "ipv6" : "ipv4"); +}; + +/** The `advanced.ipAddress` block handed to Better Auth. */ +export interface ClientIpAddressOptions { + /** + * Header names Better Auth walks, in order, to find the client IP. The + * proxy's header leads when one is configured; the server-stamped socket + * address is the fallback for requests that did not come through the proxy. + */ + readonly ipAddressHeaders: string[]; + /** + * Hops Better Auth strips from the right of a forwarded chain. Only present + * with a configured proxy; when every hop in a header is trusted, Better + * Auth finds no client in it and moves to the next header. + */ + readonly trustedProxies?: string[]; +} + +export const clientIpAddressOptions = ( + trustedProxy: TrustedProxyConfig | undefined, +): ClientIpAddressOptions => + trustedProxy + ? { + ipAddressHeaders: [trustedProxy.header, CLIENT_IP_HEADER], + trustedProxies: [...trustedProxy.proxies], + } + : { ipAddressHeaders: [CLIENT_IP_HEADER] }; + +/** + * The request with its headers replaced, everything else passed through. + * + * Deliberately NOT `new Request(request, { headers })`: Bun's copy constructor + * never delivers a body that is not one of its own native streams, and the + * Vite dev middleware hands the handler `Readable.toWeb(req)`, so every + * sign-in POST hung until the client gave up (the production Bun.serve path + * was unaffected). Rebuilding from the parts with the body passed explicitly + * behaves the same on both paths; `duplex: "half"` is what the Fetch spec + * requires when a request body is a stream. + */ +const withHeaders = (request: Request, headers: Headers): Request => { + const init: RequestInit & { duplex?: "half" } = { + method: request.method, + headers, + signal: request.signal, + }; + if (request.body) { + init.body = request.body; + init.duplex = "half"; + } + return new Request(request.url, init); +}; + +export interface ClientIpStamperOptions { + /** Where the one-time unconfigured-proxy warning goes. Defaults to console.warn. */ + readonly warn?: (message: string) => void; +} + +const unconfiguredProxyWarning = (header: string): string => + `[executor] An auth request carried ${header}, but no trusted proxy is configured, so every user behind that proxy shares one sign-in rate-limit bucket. Set EXECUTOR_TRUSTED_PROXY_HEADER and EXECUTOR_TRUSTED_PROXIES so the limit keys on the real client IP.`; + +/** + * Build the per-request rewrite that stamps the socket peer address onto + * CLIENT_IP_HEADER (or removes it when the runtime cannot report one) and + * drops the trusted-proxy header unless the peer is a configured proxy. The + * `remoteAddress` is the TCP peer as reported by the HTTP server, not anything + * read from the request. + * + * With no trusted proxy configured, the first request that carries one of + * PROXY_HINT_HEADERS triggers a single operator warning for the stamper's + * lifetime (one stamper per server). + */ +export const makeClientIpStamper = ( + trustedProxy: TrustedProxyConfig | undefined, + options: ClientIpStamperOptions = {}, +): ((request: Request, remoteAddress: Option.Option) => Request) => { + const proxies = trustedProxy + ? blockListOf(trustedProxy.proxies.flatMap((entry) => parseIpRange(entry) ?? [])) + : undefined; + const warn = options.warn ?? ((message: string) => console.warn(message)); + let warnedUnconfiguredProxy = false; + return (request, remoteAddress) => { + const headers = new Headers(request.headers); + const peer = Option.getOrUndefined(remoteAddress); + if (peer) { + headers.set(CLIENT_IP_HEADER, peer); + } else { + headers.delete(CLIENT_IP_HEADER); + } + if (trustedProxy && proxies && !(peer && isTrustedPeer(proxies, peer))) { + headers.delete(trustedProxy.header); + } + if (!trustedProxy && !warnedUnconfiguredProxy) { + const hint = PROXY_HINT_HEADERS.find((header) => headers.has(header)); + if (hint) { + warnedUnconfiguredProxy = true; + warn(unconfiguredProxyWarning(hint)); + } + } + return withHeaders(request, headers); + }; +}; diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 07dcc56d14..8bc8d70519 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -9,6 +9,8 @@ import { shouldWarnMissingPublicOrigin, } from "@executor-js/sdk/public-origin"; +import { CLIENT_IP_HEADER, parseIpRange } from "./auth/client-ip"; + // --------------------------------------------------------------------------- // Self-host server config — a single typed surface parsed from the // environment. Slice 1 keeps this a plain loader with safe defaults; it can @@ -38,6 +40,20 @@ export interface SsoConfig { readonly allowedDomains: readonly string[]; } +/** + * The reverse proxy in front of the self-host, when there is one: the header + * it sets to the real client IP and the addresses it connects from. Better + * Auth's rate limiter keys on that IP; without this it keys on the socket peer, + * which behind a proxy is the proxy itself (one shared bucket for everyone). + * Present only when the operator configured both variables together. + */ +export interface TrustedProxyConfig { + /** Lowercased header name, e.g. `cf-connecting-ip` or `x-real-ip`. */ + readonly header: string; + /** IP addresses or CIDR ranges the proxy connects from. */ + readonly proxies: readonly string[]; +} + export interface SelfHostConfig { /** Bind address. Defaults to loopback. */ readonly host: string; @@ -57,12 +73,14 @@ export interface SelfHostConfig { /** * Whether Better Auth rate-limits its own endpoints (sign-in and friends). * Better Auth turns this on in production and keys the limit on the client - * IP it reads from a trusted proxy header. With no such header every caller - * shares one bucket, so an operator who rate-limits upstream, or an - * automated suite that signs in far faster than a person, turns it off with - * `EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true`. + * IP: the socket peer the server stamps on every request, or the header a + * configured trusted proxy sets (see `trustedProxy`). An operator who + * rate-limits upstream, or an automated suite that signs in far faster than + * a person, turns it off with `EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true`. */ readonly authRateLimit: boolean; + /** Reverse proxy that asserts the client IP, or undefined when exposed directly. */ + readonly trustedProxy: TrustedProxyConfig | undefined; // Better Auth session secret. Always resolved (env, else generated + persisted // under the data dir) so a single-container deploy boots with no env; the auth // layer still validates an explicitly-set env secret is long enough. @@ -197,6 +215,7 @@ export const loadConfig = (): SelfHostConfig => { trustedOrigins: resolveTrustedOrigins(webBaseUrl), allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", authRateLimit: process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT !== "true", + trustedProxy: resolveTrustedProxy(), authSecret: resolveAuthSecret(), bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, @@ -266,6 +285,50 @@ const resolveSso = (): SsoConfig | undefined => { return { providerId, providerName, discoveryUrl, clientId, clientSecret, allowedDomains }; }; +// EXECUTOR_TRUSTED_PROXY_HEADER + EXECUTOR_TRUSTED_PROXIES — the reverse proxy +// that asserts the real client IP. The two are refused unless set together +// (same posture as resolveSso): a header with no proxy addresses would be +// honoured from anyone who can reach the container, and addresses with no +// header name nothing. Each address must be an IP or CIDR range — Better Auth +// would only log and skip a malformed entry, which quietly turns "trust the +// proxy" into "trust nobody" and pools every user into one bucket again. +const HEADER_NAME_PATTERN = /^[a-z0-9_-]+$/; + +const resolveTrustedProxy = (): TrustedProxyConfig | undefined => { + const header = process.env.EXECUTOR_TRUSTED_PROXY_HEADER?.trim().toLowerCase() ?? ""; + const proxies = (process.env.EXECUTOR_TRUSTED_PROXIES ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + if (!header && proxies.length === 0) return undefined; + if (!header || proxies.length === 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a half-configured trusted proxy + throw new Error( + "EXECUTOR_TRUSTED_PROXY_HEADER and EXECUTOR_TRUSTED_PROXIES must be set together (the header the proxy sets to the client IP, and the addresses the proxy connects from)", + ); + } + if (!HEADER_NAME_PATTERN.test(header)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_TRUSTED_PROXY_HEADER ${JSON.stringify(header)} is not an HTTP header name (e.g. "cf-connecting-ip" or "x-real-ip")`, + ); + } + if (header === CLIENT_IP_HEADER) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the stamped header is overwritten on every request, so naming it here can never read a proxy value + throw new Error( + `EXECUTOR_TRUSTED_PROXY_HEADER must not be ${JSON.stringify(CLIENT_IP_HEADER)}: the server overwrites that header with the socket address on every request`, + ); + } + const invalid = proxies.filter((entry) => parseIpRange(entry) === undefined); + if (invalid.length > 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_TRUSTED_PROXIES contains ${invalid.map((entry) => JSON.stringify(entry)).join(", ")}; each entry must be an IP address or CIDR range (e.g. "172.18.0.2" or "172.18.0.0/24")`, + ); + } + return { header, proxies }; +}; + // A malformed value is refused rather than silently ignored: an operator who // sets the knob and typos it should find out at boot, not by watching a // runaway execution use the 5-minute default. diff --git a/apps/host-selfhost/src/executor-config.test.ts b/apps/host-selfhost/src/executor-config.test.ts index 576b93820c..c8dab13e0e 100644 --- a/apps/host-selfhost/src/executor-config.test.ts +++ b/apps/host-selfhost/src/executor-config.test.ts @@ -11,6 +11,10 @@ const originalSecret = process.env[SECRET_ENV_NAME]; const originalTtl = process.env[TTL_ENV_NAME]; const RATE_LIMIT_ENV_NAME = "EXECUTOR_DISABLE_AUTH_RATE_LIMIT"; const originalRateLimit = process.env[RATE_LIMIT_ENV_NAME]; +const PROXY_HEADER_ENV_NAME = "EXECUTOR_TRUSTED_PROXY_HEADER"; +const PROXIES_ENV_NAME = "EXECUTOR_TRUSTED_PROXIES"; +const originalProxyHeader = process.env[PROXY_HEADER_ENV_NAME]; +const originalProxies = process.env[PROXIES_ENV_NAME]; beforeEach(() => { process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret"; @@ -37,6 +41,16 @@ afterEach(() => { } else { process.env[RATE_LIMIT_ENV_NAME] = originalRateLimit; } + if (originalProxyHeader === undefined) { + delete process.env[PROXY_HEADER_ENV_NAME]; + } else { + process.env[PROXY_HEADER_ENV_NAME] = originalProxyHeader; + } + if (originalProxies === undefined) { + delete process.env[PROXIES_ENV_NAME]; + } else { + process.env[PROXIES_ENV_NAME] = originalProxies; + } }); const allowStdio = (): boolean => { @@ -131,3 +145,62 @@ test("auth rate limiting is off when the opt-out is exactly true", () => { process.env[RATE_LIMIT_ENV_NAME] = "true"; expect(loadConfig().authRateLimit).toBe(false); }); + +test("no trusted proxy is configured by default", () => { + delete process.env[PROXY_HEADER_ENV_NAME]; + delete process.env[PROXIES_ENV_NAME]; + expect(loadConfig().trustedProxy).toBeUndefined(); + + process.env[PROXY_HEADER_ENV_NAME] = " "; + process.env[PROXIES_ENV_NAME] = " , "; + expect(loadConfig().trustedProxy).toBeUndefined(); +}); + +test("a trusted proxy header and address list are parsed together", () => { + process.env[PROXY_HEADER_ENV_NAME] = " CF-Connecting-IP "; + process.env[PROXIES_ENV_NAME] = "10.0.0.0/8, 192.0.2.10 ,2001:db8::/32"; + expect(loadConfig().trustedProxy).toEqual({ + header: "cf-connecting-ip", + proxies: ["10.0.0.0/8", "192.0.2.10", "2001:db8::/32"], + }); +}); + +// A header with no proxy addresses would be honoured from anyone who can reach +// the container; addresses with no header name nothing. Both refuse to boot. +test("a trusted proxy header without addresses refuses to boot", () => { + process.env[PROXY_HEADER_ENV_NAME] = "x-real-ip"; + delete process.env[PROXIES_ENV_NAME]; + expect(() => loadConfig()).toThrow(/must be set together/); +}); + +test("trusted proxy addresses without a header refuse to boot", () => { + delete process.env[PROXY_HEADER_ENV_NAME]; + process.env[PROXIES_ENV_NAME] = "10.0.0.0/8"; + expect(() => loadConfig()).toThrow(/must be set together/); +}); + +test.each(["x real ip", "x-real-ip:", "x(real)ip"])( + "a malformed trusted proxy header (%j) refuses to boot", + (raw) => { + process.env[PROXY_HEADER_ENV_NAME] = raw; + process.env[PROXIES_ENV_NAME] = "10.0.0.0/8"; + expect(() => loadConfig()).toThrow(/EXECUTOR_TRUSTED_PROXY_HEADER/); + }, +); + +test("the server-stamped header cannot be named as the proxy header", () => { + process.env[PROXY_HEADER_ENV_NAME] = "X-Executor-Client-IP"; + process.env[PROXIES_ENV_NAME] = "10.0.0.0/8"; + expect(() => loadConfig()).toThrow(/must not be "x-executor-client-ip"/); +}); + +// Better Auth would only warn and skip a bad entry, silently leaving every +// user in one bucket; refuse at boot and name the entry instead. +test.each(["proxy.example.com", "10.0.0.0/33", "10.0.0/8", "*"])( + "a malformed trusted proxy address (%j) refuses to boot", + (raw) => { + process.env[PROXY_HEADER_ENV_NAME] = "x-real-ip"; + process.env[PROXIES_ENV_NAME] = `10.0.0.0/8,${raw}`; + expect(() => loadConfig()).toThrow(`EXECUTOR_TRUSTED_PROXIES contains ${JSON.stringify(raw)}`); + }, +);