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
3"executor": patch3"executor": patch
4---4---
5 5
6Key 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.6Key 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
8Behaviour 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
118nothing needs configuring.118nothing needs configuring.
119 119
120Behind Caddy, nginx, Cloudflare, or another reverse proxy, every connection120Behind Caddy, nginx, Cloudflare, or another reverse proxy, every connection
121comes from the proxy. Tell Executor which header the proxy sets to the real121comes from the proxy. If Executor runs behind a reverse proxy, set both
122client IP, and which addresses the proxy connects from:122variables, otherwise all users share one sign-in bucket. Tell Executor which
123header the proxy sets to the real client IP, and which addresses the proxy
124connects from:
123 125
124```bash126```bash
125-e EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip \127-e EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip \
126-e EXECUTOR_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12128-e EXECUTOR_TRUSTED_PROXIES=172.18.0.2
127```129```
128 130
129Set both together. The header is only honoured on connections from one of the131Set both together. The header is only honoured on connections from one of the
130listed addresses, so a client that reaches the container directly cannot spoof132listed addresses, so a client that reaches the container directly cannot spoof
131it. Use `cf-connecting-ip` with Cloudflare's published IP ranges, `x-real-ip`133it. List only the proxy's own addresses (its IP, or the compose network it
132for nginx (`proxy_set_header X-Real-IP $remote_addr;`), or `x-forwarded-for`134shares with Executor, such as `172.18.0.0/24`), never a range that also
133for Caddy. A malformed entry refuses to boot rather than silently pooling every135contains your users: every listed address counts as a proxy hop, and if every
136hop is trusted Better Auth finds no client and falls back to one shared bucket.
137Use `cf-connecting-ip` with Cloudflare's published IP ranges, `x-real-ip` for
138nginx (`proxy_set_header X-Real-IP $remote_addr;`), or `x-forwarded-for` for
139Caddy. A malformed entry refuses to boot rather than silently pooling every
134user into one bucket.140user into one bucket.
135 141
136Without this, an instance behind a proxy shares one sign-in bucket across all142Executor never trusts `x-forwarded-for` or a similar header on its own, because
137users. Docker Desktop and Docker's userland proxy also present every connection143any client could set it. When an auth request carries one of those headers and
138from one internal address; put a reverse proxy in front and configure it as144no trusted proxy is configured, the server logs one warning naming these two
139above.145variables.
146
147On Linux, Docker's default iptables NAT preserves the client IP on a published
148port, so a container reached directly needs nothing. Docker Desktop (macOS and
149Windows) routes published ports through Docker's userland proxy, as does
150loopback traffic from the Docker host itself, so every connection arrives from
151one internal address; there, put a reverse proxy on the same Docker network in
152front of Executor and configure it as above.
140 153
141## Connect an agent154## Connect an agent
142 155
apps/host-selfhost/.env.example+8−4
@@ -42,11 +42,15 @@ · 41 unmodified lines
42#42#
43# Behind a reverse proxy (Caddy, nginx, Cloudflare, ...) every connection comes43# Behind a reverse proxy (Caddy, nginx, Cloudflare, ...) every connection comes
44# from the proxy, so name the header the proxy sets to the real client IP and44# 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 honoured45# the addresses the proxy connects from. Set BOTH, otherwise all users share
46# on connections from those addresses, so it cannot be spoofed by a client that46# one sign-in bucket. The header is only honoured on connections from those
47# reaches the container directly.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.
48# EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip52# EXECUTOR_TRUSTED_PROXY_HEADER=x-real-ip
49# EXECUTOR_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/1253# EXECUTOR_TRUSTED_PROXIES=172.18.0.2
50#54#
51# Set the exact string "true" only when something in front of Executor55# Set the exact string "true" only when something in front of Executor
52# rate-limits sign-ins instead.56# 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
14import { Context } from "effect";14import { Context } from "effect";
15 15
16import { loadConfig } from "../config";16import { loadConfig } from "../config";
17import { clientIpHeaders } from "./client-ip";17import { clientIpAddressOptions } from "./client-ip";
18import { seedOrgAndAdmin } from "./seed";18import { seedOrgAndAdmin } from "./seed";
19import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites";19import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites";
20import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso";20import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso";
@@ -139,10 +139,7 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 118 unmodified lines
139 // `trustedProxies` lets Better Auth strip known hops from a forwarded139 // `trustedProxies` lets Better Auth strip known hops from a forwarded
140 // chain; the stamper has already dropped the proxy header on any140 // chain; the stamper has already dropped the proxy header on any
141 // connection that did not come from one of those addresses.141 // connection that did not come from one of those addresses.
142 ipAddress: {142 ipAddress: clientIpAddressOptions(config.trustedProxy),
143 ipAddressHeaders: clientIpHeaders(config.trustedProxy),
144 ...(config.trustedProxy ? { trustedProxies: [...config.trustedProxy.proxies] } : {}),
145 },
146 },143 },
147 // Better Auth's own limiter is on in production and off in development.144 // Better Auth's own limiter is on in production and off in development.
148 // Only an explicit opt-out is passed through, so that environment default145 // 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 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Option } from "effect";2import { Option } from "effect";
3 3
4import { CLIENT_IP_HEADER, clientIpHeaders, makeClientIpStamper, parseIpRange } from "./client-ip";4import {
5 CLIENT_IP_HEADER,
6 PROXY_HINT_HEADERS,
7 clientIpAddressOptions,
8 makeClientIpStamper,
9 parseIpRange,
10} from "./client-ip";
5 11
6const signIn = (headers: Record<string, string> = {}) =>12const signIn = (headers: Record<string, string> = {}) =>
7 new Request("https://host.example/api/auth/sign-in/email", {13 new Request("https://host.example/api/auth/sign-in/email", {
@@ -41,21 +47,26 @@ describe("parseIpRange", () => { · 33 unmodified lines
41 );47 );
42});48});
43 49
44describe("clientIpHeaders", () => {50describe("clientIpAddressOptions", () => {
45 it("reads only the stamped header with no proxy configured", () => {51 it("reads only the stamped header, with no trusted proxies, when no proxy is configured", () => {
46 expect(clientIpHeaders(undefined)).toEqual([CLIENT_IP_HEADER]);52 expect(clientIpAddressOptions(undefined)).toStrictEqual({
53 ipAddressHeaders: [CLIENT_IP_HEADER],
54 });
47 });55 });
48 56
49 it("reads the proxy header first, then the stamped header", () => {57 it("reads the proxy header first, then the stamped header, and trusts the proxy ranges", () => {
50 expect(clientIpHeaders({ header: "cf-connecting-ip", proxies: ["10.0.0.0/8"] })).toEqual([58 expect(
51 "cf-connecting-ip",59 clientIpAddressOptions({ header: "cf-connecting-ip", proxies: ["10.0.0.0/8", "192.0.2.10"] }),
52 CLIENT_IP_HEADER,60 ).toStrictEqual({
53 ]);61 ipAddressHeaders: ["cf-connecting-ip", CLIENT_IP_HEADER],
62 trustedProxies: ["10.0.0.0/8", "192.0.2.10"],
63 });
54 });64 });
55});65});
56 66
57describe("makeClientIpStamper without a trusted proxy", () => {67describe("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: () => {} });
59 70
60 it("overwrites a client-supplied header with the socket peer address", async () => {71 it("overwrites a client-supplied header with the socket peer address", async () => {
61 const out = stamp(signIn({ [CLIENT_IP_HEADER]: "203.0.113.9" }), Option.some("198.51.100.4"));72 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
118 expect(out.headers.has(CLIENT_IP_HEADER)).toBe(false);129 expect(out.headers.has(CLIENT_IP_HEADER)).toBe(false);
119 });130 });
120});131});
132
133describe("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
20// it connects from (EXECUTOR_TRUSTED_PROXIES). That header is honoured only20// it connects from (EXECUTOR_TRUSTED_PROXIES). That header is honoured only
21// on connections from one of those addresses and stripped otherwise, so a21// on connections from one of those addresses and stripped otherwise, so a
22// client that reaches the container directly cannot assert a proxy header.22// 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.
23// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
24 29
25/** Server-stamped socket peer address. Never trusted from the client. */30/** Server-stamped socket peer address. Never trusted from the client. */
26export const CLIENT_IP_HEADER = "x-executor-client-ip";31export const CLIENT_IP_HEADER = "x-executor-client-ip";
27 32
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 */
38export const PROXY_HINT_HEADERS = [
39 "x-forwarded-for",
40 "x-real-ip",
41 "cf-connecting-ip",
42 "true-client-ip",
43] as const;
44
28export interface IpRange {45export interface IpRange {
29 readonly address: string;46 readonly address: string;
30 readonly prefix: number;47 readonly prefix: number;
@@ -64,13 +81,39 @@ const isTrustedPeer = (list: BlockList, address: string): boolean => { · 33 unmodified lines
64 return version !== 0 && list.check(address, version === 6 ? "ipv6" : "ipv4");81 return version !== 0 && list.check(address, version === 6 ? "ipv6" : "ipv4");
65};82};
66 83
67/**84/** The `advanced.ipAddress` block handed to Better Auth. */
68 * Header names Better Auth walks, in order, to find the client IP. The proxy's85export interface ClientIpAddressOptions {
69 * header leads when one is configured; the server-stamped socket address is the86 /**
70 * fallback for requests that did not come through the proxy.87 * Header names Better Auth walks, in order, to find the client IP. The
71 */88 * proxy's header leads when one is configured; the server-stamped socket
72export const clientIpHeaders = (trustedProxy: TrustedProxyConfig | undefined): string[] =>89 * address is the fallback for requests that did not come through the proxy.
73 trustedProxy ? [trustedProxy.header, CLIENT_IP_HEADER] : [CLIENT_IP_HEADER];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
100export 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
110export interface ClientIpStamperOptions {
111 /** Where the one-time unconfigured-proxy warning goes. Defaults to console.warn. */
112 readonly warn?: (message: string) => void;
113}
114
115const 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.`;
74 117
75/**118/**
76 * Build the per-request rewrite that stamps the socket peer address onto119 * 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
78 * drops the trusted-proxy header unless the peer is a configured proxy. The121 * drops the trusted-proxy header unless the peer is a configured proxy. The
79 * `remoteAddress` is the TCP peer as reported by the HTTP server, not anything122 * `remoteAddress` is the TCP peer as reported by the HTTP server, not anything
80 * read from the request.123 * 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).
81 */128 */
82export const makeClientIpStamper = (129export const makeClientIpStamper = (
83 trustedProxy: TrustedProxyConfig | undefined,130 trustedProxy: TrustedProxyConfig | undefined,
131 options: ClientIpStamperOptions = {},
84): ((request: Request, remoteAddress: Option.Option<string>) => Request) => {132): ((request: Request, remoteAddress: Option.Option<string>) => Request) => {
85 const proxies = trustedProxy133 const proxies = trustedProxy
86 ? blockListOf(trustedProxy.proxies.flatMap((entry) => parseIpRange(entry) ?? []))134 ? blockListOf(trustedProxy.proxies.flatMap((entry) => parseIpRange(entry) ?? []))
87 : undefined;135 : undefined;
136 const warn = options.warn ?? ((message: string) => console.warn(message));
137 let warnedUnconfiguredProxy = false;
88 return (request, remoteAddress) => {138 return (request, remoteAddress) => {
89 const headers = new Headers(request.headers);139 const headers = new Headers(request.headers);
90 const peer = Option.getOrUndefined(remoteAddress);140 const peer = Option.getOrUndefined(remoteAddress);
@@ -96,6 +146,13 @@ export const makeClientIpStamper = ( · 5 unmodified lines
96 if (trustedProxy && proxies && !(peer && isTrustedPeer(proxies, peer))) {146 if (trustedProxy && proxies && !(peer && isTrustedPeer(proxies, peer))) {
97 headers.delete(trustedProxy.header);147 headers.delete(trustedProxy.header);
98 }148 }
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 }
99 return new Request(request, { headers });156 return new Request(request, { headers });
100 };157 };
101};158};
apps/host-selfhost/src/config.ts+1−1
@@ -323,7 +323,7 @@ const resolveTrustedProxy = (): TrustedProxyConfig | undefined => { · 322 unmodified lines
323 if (invalid.length > 0) {323 if (invalid.length > 0) {
324 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob324 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
325 throw new Error(325 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")`,
327 );327 );
328 }328 }
329 return { header, proxies };329 return { header, proxies };