History

Warn once on unconfigured proxy; fix trusted-proxy examples

Rhys Sullivan committed 1 month ago

Commit 0ff9d4f1 parent 3fb9a487 files · +172 −38Browse files

Unified · Split

7 files
  1. .changeset/selfhost-auth-client-ip.md +3 −1
  2. apps/docs/hosted/docker.mdx +23 −10
  3. apps/host-selfhost/.env.example +8 −4
  4. apps/host-selfhost/src/auth/better-auth.ts +2 −5
  5. apps/host-selfhost/src/auth/client-ip.test.ts +71 −10
  6. apps/host-selfhost/src/auth/client-ip.ts +64 −7
  7. apps/host-selfhost/src/config.ts +1 −1
.changeset/selfhost-auth-client-ip.md+3−1
@@ -3,4 +3,6 @@ · 2 unmodified lines
33 "executor": patch
44 ---
55
6−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. Behind a reverse proxy, set `EXECUTOR_TRUSTED_PROXY_HEADER` (the header the proxy sets, e.g. `cf-connecting-ip` or `x-real-ip`) and `EXECUTOR_TRUSTED_PROXIES` (the IPs or CIDR ranges the proxy connects from) together; the header is honoured only on connections from those addresses. Half-configured or malformed values refuse to boot.
6+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.
7+
8+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.
apps/docs/hosted/docker.mdx+23−10
@@ -118,25 +118,38 @@ browsers reach the container directly, the IP is the connecting address and · 117 unmodified lines
118118 nothing needs configuring.
119119
120120 Behind Caddy, nginx, Cloudflare, or another reverse proxy, every connection
121−comes from the proxy. Tell Executor which header the proxy sets to the real
122−client IP, and which addresses the proxy connects from:
121+comes from the proxy. If Executor runs behind a reverse proxy, set both
122+variables, otherwise all users share one sign-in bucket. Tell Executor which
123+header the proxy sets to the real client IP, and which addresses the proxy
124+connects from:
123125
124126 ```bash
125127 -e EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip \
126−-e EXECUTOR_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
128+-e EXECUTOR_TRUSTED_PROXIES=172.18.0.2
127129 ```
128130
129131 Set both together. The header is only honoured on connections from one of the
130132 listed addresses, so a client that reaches the container directly cannot spoof
131−it. Use `cf-connecting-ip` with Cloudflare's published IP ranges, `x-real-ip`
132−for nginx (`proxy_set_header X-Real-IP $remote_addr;`), or `x-forwarded-for`
133−for Caddy. A malformed entry refuses to boot rather than silently pooling every
133+it. List only the proxy's own addresses (its IP, or the compose network it
134+shares with Executor, such as `172.18.0.0/24`), never a range that also
135+contains your users: every listed address counts as a proxy hop, and if every
136+hop is trusted Better Auth finds no client and falls back to one shared bucket.
137+Use `cf-connecting-ip` with Cloudflare's published IP ranges, `x-real-ip` for
138+nginx (`proxy_set_header X-Real-IP $remote_addr;`), or `x-forwarded-for` for
139+Caddy. A malformed entry refuses to boot rather than silently pooling every
134140 user into one bucket.
135141
136−Without this, an instance behind a proxy shares one sign-in bucket across all
137−users. Docker Desktop and Docker's userland proxy also present every connection
138−from one internal address; put a reverse proxy in front and configure it as
139−above.
142+Executor never trusts `x-forwarded-for` or a similar header on its own, because
143+any client could set it. When an auth request carries one of those headers and
144+no trusted proxy is configured, the server logs one warning naming these two
145+variables.
146+
147+On Linux, Docker's default iptables NAT preserves the client IP on a published
148+port, so a container reached directly needs nothing. Docker Desktop (macOS and
149+Windows) routes published ports through Docker's userland proxy, as does
150+loopback traffic from the Docker host itself, so every connection arrives from
151+one internal address; there, put a reverse proxy on the same Docker network in
152+front of Executor and configure it as above.
140153
141154 ## Connect an agent
142155
apps/host-selfhost/.env.example+8−4
@@ -42,11 +42,15 @@ · 41 unmodified lines
4242 #
4343 # Behind a reverse proxy (Caddy, nginx, Cloudflare, ...) every connection comes
4444 # from the proxy, so name the header the proxy sets to the real client IP and
45−# the addresses the proxy connects from. Set BOTH; the header is only honoured
46−# on connections from those addresses, so it cannot be spoofed by a client that
47−# reaches the container directly.
45+# the addresses the proxy connects from. Set BOTH, otherwise all users share
46+# one sign-in bucket. The header is only honoured on connections from those
47+# addresses, so it cannot be spoofed by a client that reaches the container
48+# directly. List only the proxy's own addresses (its IP, or the compose network
49+# it shares with Executor, e.g. 172.18.0.0/24), never a range that also
50+# contains your users: if every hop is trusted no client IP is found and
51+# everyone shares one bucket again.
4852 # EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip
49−# EXECUTOR_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
53+# EXECUTOR_TRUSTED_PROXIES=172.18.0.2
5054 #
5155 # Set the exact string "true" only when something in front of Executor
5256 # rate-limits sign-ins instead.
apps/host-selfhost/src/auth/better-auth.ts+2−5
@@ -14,7 +14,7 @@ import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; · 13 unmodified lines
1414 import { Context } from "effect";
1515
1616 import { loadConfig } from "../config";
17−import { clientIpHeaders } from "./client-ip";
17+import { clientIpAddressOptions } from "./client-ip";
1818 import { seedOrgAndAdmin } from "./seed";
1919 import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites";
2020 import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso";
@@ -139,10 +139,7 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 118 unmodified lines
139139 // `trustedProxies` lets Better Auth strip known hops from a forwarded
140140 // chain; the stamper has already dropped the proxy header on any
141141 // connection that did not come from one of those addresses.
142− ipAddress: {
143− ipAddressHeaders: clientIpHeaders(config.trustedProxy),
144− ...(config.trustedProxy ? { trustedProxies: [...config.trustedProxy.proxies] } : {}),
145− },
142+ ipAddress: clientIpAddressOptions(config.trustedProxy),
146143 },
147144 // Better Auth's own limiter is on in production and off in development.
148145 // Only an explicit opt-out is passed through, so that environment default
apps/host-selfhost/src/auth/client-ip.test.ts+71−10
@@ -1,7 +1,13 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Option } from "effect";
33
4−import { CLIENT_IP_HEADER, clientIpHeaders, makeClientIpStamper, parseIpRange } from "./client-ip";
4+import {
5+ CLIENT_IP_HEADER,
6+ PROXY_HINT_HEADERS,
7+ clientIpAddressOptions,
8+ makeClientIpStamper,
9+ parseIpRange,
10+} from "./client-ip";
511
612 const signIn = (headers: Record<string, string> = {}) =>
713 new Request("https://host.example/api/auth/sign-in/email", {
@@ -41,21 +47,26 @@ describe("parseIpRange", () => { · 33 unmodified lines
4147 );
4248 });
4349
44−describe("clientIpHeaders", () => {
45− it("reads only the stamped header with no proxy configured", () => {
46− expect(clientIpHeaders(undefined)).toEqual([CLIENT_IP_HEADER]);
50+describe("clientIpAddressOptions", () => {
51+ it("reads only the stamped header, with no trusted proxies, when no proxy is configured", () => {
52+ expect(clientIpAddressOptions(undefined)).toStrictEqual({
53+ ipAddressHeaders: [CLIENT_IP_HEADER],
54+ });
4755 });
4856
49− it("reads the proxy header first, then the stamped header", () => {
50− expect(clientIpHeaders({ header: "cf-connecting-ip", proxies: ["10.0.0.0/8"] })).toEqual([
51− "cf-connecting-ip",
52− CLIENT_IP_HEADER,
53− ]);
57+ it("reads the proxy header first, then the stamped header, and trusts the proxy ranges", () => {
58+ expect(
59+ clientIpAddressOptions({ header: "cf-connecting-ip", proxies: ["10.0.0.0/8", "192.0.2.10"] }),
60+ ).toStrictEqual({
61+ ipAddressHeaders: ["cf-connecting-ip", CLIENT_IP_HEADER],
62+ trustedProxies: ["10.0.0.0/8", "192.0.2.10"],
63+ });
5464 });
5565 });
5666
5767 describe("makeClientIpStamper without a trusted proxy", () => {
58− const stamp = makeClientIpStamper(undefined);
68+ // The warning path has its own suite below; keep it out of this one's output.
69+ const stamp = makeClientIpStamper(undefined, { warn: () => {} });
5970
6071 it("overwrites a client-supplied header with the socket peer address", async () => {
6172 const out = stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.some("198.51.100.4"));
@@ -118,3 +129,53 @@ describe("makeClientIpStamper behind a trusted proxy", () => { · 56 unmodified lines
118129 expect(out.headers.has(CLIENT_IP_HEADER)).toBe(false);
119130 });
120131 });
132+
133+describe("unconfigured-proxy warning", () => {
134+ const stamperWithWarnings = (trustedProxy?: { header: string; proxies: string[] }) => {
135+ const warnings: string[] = [];
136+ const stamp = makeClientIpStamper(trustedProxy, {
137+ warn: (message) => {
138+ warnings.push(message);
139+ },
140+ });
141+ return { stamp, warnings };
142+ };
143+
144+ it.each(PROXY_HINT_HEADERS)(
145+ "fires once for %s with no trusted proxy configured, naming both variables",
146+ (header) => {
147+ const { stamp, warnings } = stamperWithWarnings();
148+ stamp(signIn({ [header]: "203.0.113.9" }), Option.some("172.18.0.2"));
149+ expect(warnings).toHaveLength(1);
150+ expect(warnings[0]).toContain(header);
151+ expect(warnings[0]).toContain("EXECUTOR_TRUSTED_PROXY_HEADER");
152+ expect(warnings[0]).toContain("EXECUTOR_TRUSTED_PROXIES");
153+
154+ stamp(signIn({ [header]: "203.0.113.9" }), Option.some("172.18.0.2"));
155+ stamp(signIn({ "x-forwarded-for": "203.0.113.10" }), Option.some("172.18.0.2"));
156+ expect(warnings).toHaveLength(1);
157+ },
158+ );
159+
160+ it("does not fire for a request without a proxy-style header", () => {
161+ const { stamp, warnings } = stamperWithWarnings();
162+ stamp(signIn(), Option.some("198.51.100.4"));
163+ stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.some("198.51.100.4"));
164+ expect(warnings).toHaveLength(0);
165+ });
166+
167+ it("does not fire when a trusted proxy is configured", () => {
168+ const { stamp, warnings } = stamperWithWarnings({
169+ header: "x-real-ip",
170+ proxies: ["172.18.0.2"],
171+ });
172+ // From the proxy, with the configured header plus the extras nginx sends.
173+ stamp(
174+ signIn({ "x-real-ip": "203.0.113.9", "x-forwarded-for": "203.0.113.9" }),
175+ Option.some("172.18.0.2"),
176+ );
177+ // Direct from an untrusted peer asserting a proxy header.
178+ stamp(signIn({ "x-forwarded-for": "203.0.113.9" }), Option.some("198.51.100.4"));
179+ expect(warnings).toHaveLength(0);
180+ });
181+});
apps/host-selfhost/src/auth/client-ip.ts+64−7
@@ -20,11 +20,28 @@ import type { TrustedProxyConfig } from "../config"; · 19 unmodified lines
2020 // it connects from (EXECUTOR_TRUSTED_PROXIES). That header is honoured only
2121 // on connections from one of those addresses and stripped otherwise, so a
2222 // client that reaches the container directly cannot assert a proxy header.
23+//
24+// Better Auth's own default reads `x-forwarded-for` from anyone. That is not
25+// restored here: a client could rotate the header to dodge the limit. Behind
26+// an unconfigured proxy every user therefore shares one bucket, so the stamper
27+// warns the operator once when it sees a proxy-style header in that state.
2328 // ---------------------------------------------------------------------------
2429
2530 /** Server-stamped socket peer address. Never trusted from the client. */
2631 export const CLIENT_IP_HEADER = "x-executor-client-ip";
2732
33+/**
34+ * Headers a reverse proxy commonly sets to the client IP. Seeing one with no
35+ * trusted proxy configured is the signature of a proxied deployment that has
36+ * not told Executor about its proxy.
37+ */
38+export const PROXY_HINT_HEADERS = [
39+ "x-forwarded-for",
40+ "x-real-ip",
41+ "cf-connecting-ip",
42+ "true-client-ip",
43+] as const;
44+
2845 export interface IpRange {
2946 readonly address: string;
3047 readonly prefix: number;
@@ -64,13 +81,39 @@ const isTrustedPeer = (list: BlockList, address: string): boolean => { · 33 unmodified lines
6481 return version !== 0 && list.check(address, version === 6 ? "ipv6" : "ipv4");
6582 };
6683
67−/**
68− * Header names Better Auth walks, in order, to find the client IP. The proxy's
69− * header leads when one is configured; the server-stamped socket address is the
70− * fallback for requests that did not come through the proxy.
71− */
72−export const clientIpHeaders = (trustedProxy: TrustedProxyConfig | undefined): string[] =>
73− trustedProxy ? [trustedProxy.header, CLIENT_IP_HEADER] : [CLIENT_IP_HEADER];
84+/** The `advanced.ipAddress` block handed to Better Auth. */
85+export interface ClientIpAddressOptions {
86+ /**
87+ * Header names Better Auth walks, in order, to find the client IP. The
88+ * proxy's header leads when one is configured; the server-stamped socket
89+ * address is the fallback for requests that did not come through the proxy.
90+ */
91+ readonly ipAddressHeaders: string[];
92+ /**
93+ * Hops Better Auth strips from the right of a forwarded chain. Only present
94+ * with a configured proxy; when every hop in a header is trusted, Better
95+ * Auth finds no client in it and moves to the next header.
96+ */
97+ readonly trustedProxies?: string[];
98+}
99+
100+export const clientIpAddressOptions = (
101+ trustedProxy: TrustedProxyConfig | undefined,
102+): ClientIpAddressOptions =>
103+ trustedProxy
104+ ? {
105+ ipAddressHeaders: [trustedProxy.header, CLIENT_IP_HEADER],
106+ trustedProxies: [...trustedProxy.proxies],
107+ }
108+ : { ipAddressHeaders: [CLIENT_IP_HEADER] };
109+
110+export interface ClientIpStamperOptions {
111+ /** Where the one-time unconfigured-proxy warning goes. Defaults to console.warn. */
112+ readonly warn?: (message: string) => void;
113+}
114+
115+const unconfiguredProxyWarning = (header: string): string =>
116+ `[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.`;
74117
75118 /**
76119 * Build the per-request rewrite that stamps the socket peer address onto
@@ -78,13 +121,20 @@ export const clientIpHeaders = (trustedProxy: TrustedProxyConfig | undefined): s · 1 unmodified line
78121 * drops the trusted-proxy header unless the peer is a configured proxy. The
79122 * `remoteAddress` is the TCP peer as reported by the HTTP server, not anything
80123 * read from the request.
124+ *
125+ * With no trusted proxy configured, the first request that carries one of
126+ * PROXY_HINT_HEADERS triggers a single operator warning for the stamper's
127+ * lifetime (one stamper per server).
81128 */
82129 export const makeClientIpStamper = (
83130 trustedProxy: TrustedProxyConfig | undefined,
131+ options: ClientIpStamperOptions = {},
84132 ): ((request: Request, remoteAddress: Option.Option<string>) => Request) => {
85133 const proxies = trustedProxy
86134 ? blockListOf(trustedProxy.proxies.flatMap((entry) => parseIpRange(entry) ?? []))
87135 : undefined;
136+ const warn = options.warn ?? ((message: string) => console.warn(message));
137+ let warnedUnconfiguredProxy = false;
88138 return (request, remoteAddress) => {
89139 const headers = new Headers(request.headers);
90140 const peer = Option.getOrUndefined(remoteAddress);
@@ -96,6 +146,13 @@ export const makeClientIpStamper = ( · 5 unmodified lines
96146 if (trustedProxy && proxies && !(peer && isTrustedPeer(proxies, peer))) {
97147 headers.delete(trustedProxy.header);
98148 }
149+ if (!trustedProxy && !warnedUnconfiguredProxy) {
150+ const hint = PROXY_HINT_HEADERS.find((header) => headers.has(header));
151+ if (hint) {
152+ warnedUnconfiguredProxy = true;
153+ warn(unconfiguredProxyWarning(hint));
154+ }
155+ }
99156 return new Request(request, { headers });
100157 };
101158 };
apps/host-selfhost/src/config.ts+1−1
@@ -323,7 +323,7 @@ const resolveTrustedProxy = (): TrustedProxyConfig | undefined => { · 322 unmodified lines
323323 if (invalid.length > 0) {
324324 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
325325 throw new Error(
326− `EXECUTOR_TRUSTED_PROXIES contains ${invalid.map((entry) => JSON.stringify(entry)).join(", ")}; each entry must be an IP address or CIDR range (e.g. "10.0.0.0/8")`,
326+ `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")`,
327327 );
328328 }
329329 return { header, proxies };