History

Add a self-host opt-out for Better Auth rate limiting (#2044)

Rhys Sullivan committed 1 month ago

Commit 004024b1 parent 5e679507 files · +51 −0Browse files

Unified · Split

7 files
  1. .changeset/selfhost-auth-rate-limit.md +6 −0
  2. apps/docs/hosted/docker.mdx +1 −0
  3. apps/host-selfhost/.env.example +6 −0
  4. apps/host-selfhost/src/auth/better-auth.ts +4 −0
  5. apps/host-selfhost/src/config.ts +10 −0
  6. apps/host-selfhost/src/executor-config.test.ts +19 −0
  7. e2e/setup/selfhost-docker.boot.ts +5 −0
.changeset/selfhost-auth-rate-limit.md+6−0
@@ -0,0 +1,6 @@
1---
2"@executor-js/host-selfhost": patch
3"executor": patch
4---
5
6Add `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` to the self-host. Better Auth 1.6.17 and later enforce sign-in rate limits strictly in production, and with no trusted proxy header every caller shares one bucket of three sign-ins per ten seconds. The Docker release gate signs in from many test files at once and tripped it. The flag is off by default; the e2e harness sets it for the image it tests.
apps/docs/hosted/docker.mdx+1−0
@@ -69,6 +69,7 @@ the container defaults. · 68 unmodified lines
69| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. |69| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. |
70| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. |70| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. |
71| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. |71| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. |
72| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. |
72 73
73Tracing is configured separately, and off unless you turn it on — see74Tracing is configured separately, and off unless you turn it on — see
74[Tracing](/hosted/tracing).75[Tracing](/hosted/tracing).
apps/host-selfhost/.env.example+6−0
@@ -36,6 +36,12 @@ · 35 unmodified lines
36# default — adversarial generated code should not reach your internal network.36# default — adversarial generated code should not reach your internal network.
37# EXECUTOR_ALLOW_LOCAL_NETWORK=false37# EXECUTOR_ALLOW_LOCAL_NETWORK=false
38 38
39# --- Auth rate limiting -------------------------------------------------------
40# Sign-in attempts are rate-limited per client IP. Without a trusted proxy
41# header every caller shares one bucket. Set the exact string "true" only when
42# something in front of Executor rate-limits instead.
43# EXECUTOR_DISABLE_AUTH_RATE_LIMIT=false
44
39# --- Local stdio MCP (trusted deployments only) -------------------------------45# --- Local stdio MCP (trusted deployments only) -------------------------------
40# Stdio MCP is disabled unless this is explicitly set to the exact string46# Stdio MCP is disabled unless this is explicitly set to the exact string
41# "true". Enabling it lets users configure MCP servers whose commands execute47# "true". Enabling it lets users configure MCP servers whose commands execute
apps/host-selfhost/src/auth/better-auth.ts+4−0
@@ -130,6 +130,10 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 129 unmodified lines
130 baseURL: config.webBaseUrl,130 baseURL: config.webBaseUrl,
131 trustedOrigins: [...config.trustedOrigins],131 trustedOrigins: [...config.trustedOrigins],
132 advanced: { useSecureCookies: !hasInsecureTrustedOrigin },132 advanced: { useSecureCookies: !hasInsecureTrustedOrigin },
133 // Better Auth's own limiter is on in production and off in development.
134 // Only an explicit opt-out is passed through, so that environment default
135 // stays in charge everywhere else.
136 ...(config.authRateLimit ? {} : { rateLimit: { enabled: false } }),
133 emailAndPassword: { enabled: true },137 emailAndPassword: { enabled: true },
134 // `apiKey` issues long-lived personal keys (the API-keys page). With138 // `apiKey` issues long-lived personal keys (the API-keys page). With
135 // `enableSessionForAPIKeys`, presenting a key resolves to its owner's139 // `enableSessionForAPIKeys`, presenting a key resolves to its owner's
apps/host-selfhost/src/config.ts+10−0
@@ -54,6 +54,15 @@ export interface SelfHostConfig { · 53 unmodified lines
54 * internal network unless an operator opts in.54 * internal network unless an operator opts in.
55 */55 */
56 readonly allowLocalNetwork: boolean;56 readonly allowLocalNetwork: boolean;
57 /**
58 * Whether Better Auth rate-limits its own endpoints (sign-in and friends).
59 * Better Auth turns this on in production and keys the limit on the client
60 * IP it reads from a trusted proxy header. With no such header every caller
61 * shares one bucket, so an operator who rate-limits upstream, or an
62 * automated suite that signs in far faster than a person, turns it off with
63 * `EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true`.
64 */
65 readonly authRateLimit: boolean;
57 // Better Auth session secret. Always resolved (env, else generated + persisted66 // Better Auth session secret. Always resolved (env, else generated + persisted
58 // under the data dir) so a single-container deploy boots with no env; the auth67 // under the data dir) so a single-container deploy boots with no env; the auth
59 // layer still validates an explicitly-set env secret is long enough.68 // layer still validates an explicitly-set env secret is long enough.
@@ -187,6 +196,7 @@ export const loadConfig = (): SelfHostConfig => { · 127 unmodified lines
187 webBaseUrl,196 webBaseUrl,
188 trustedOrigins: resolveTrustedOrigins(webBaseUrl),197 trustedOrigins: resolveTrustedOrigins(webBaseUrl),
189 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",198 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
199 authRateLimit: process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT !== "true",
190 authSecret: resolveAuthSecret(),200 authSecret: resolveAuthSecret(),
191 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,201 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
192 bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,202 bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,
apps/host-selfhost/src/executor-config.test.ts+19−0
@@ -9,6 +9,8 @@ const TTL_ENV_NAME = "EXECUTOR_TOOLS_SYNC_TTL_MS"; · 8 unmodified lines
9const originalValue = process.env[ENV_NAME];9const originalValue = process.env[ENV_NAME];
10const originalSecret = process.env[SECRET_ENV_NAME];10const originalSecret = process.env[SECRET_ENV_NAME];
11const originalTtl = process.env[TTL_ENV_NAME];11const originalTtl = process.env[TTL_ENV_NAME];
12const RATE_LIMIT_ENV_NAME = "EXECUTOR_DISABLE_AUTH_RATE_LIMIT";
13const originalRateLimit = process.env[RATE_LIMIT_ENV_NAME];
12 14
13beforeEach(() => {15beforeEach(() => {
14 process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret";16 process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret";
@@ -30,6 +32,11 @@ afterEach(() => { · 15 unmodified lines
30 } else {32 } else {
31 process.env[TTL_ENV_NAME] = originalTtl;33 process.env[TTL_ENV_NAME] = originalTtl;
32 }34 }
35 if (originalRateLimit === undefined) {
36 delete process.env[RATE_LIMIT_ENV_NAME];
37 } else {
38 process.env[RATE_LIMIT_ENV_NAME] = originalRateLimit;
39 }
33});40});
34 41
35const allowStdio = (): boolean => {42const allowStdio = (): boolean => {
@@ -112,3 +119,15 @@ test("a negative tools-sync TTL refuses to boot", () => { · 76 unmodified lines
112 process.env[TTL_ENV_NAME] = "-1";119 process.env[TTL_ENV_NAME] = "-1";
113 expect(() => loadConfig()).toThrow(/must not be negative/);120 expect(() => loadConfig()).toThrow(/must not be negative/);
114});121});
122
123test("auth rate limiting stays on unless the opt-out is exactly true", () => {
124 delete process.env[RATE_LIMIT_ENV_NAME];
125 expect(loadConfig().authRateLimit).toBe(true);
126 process.env[RATE_LIMIT_ENV_NAME] = "TRUE";
127 expect(loadConfig().authRateLimit).toBe(true);
128});
129
130test("auth rate limiting is off when the opt-out is exactly true", () => {
131 process.env[RATE_LIMIT_ENV_NAME] = "true";
132 expect(loadConfig().authRateLimit).toBe(false);
133});
e2e/setup/selfhost-docker.boot.ts+5−0
@@ -108,6 +108,11 @@ export const runSelfhostContainer = async (options: RunContainerOptions): Promis · 107 unmodified lines
108 // test servers and points the instance at them.108 // test servers and points the instance at them.
109 "-e",109 "-e",
110 "EXECUTOR_ALLOW_LOCAL_NETWORK=true",110 "EXECUTOR_ALLOW_LOCAL_NETWORK=true",
111 // The production image runs Better Auth's rate limiter. It sees no proxy
112 // header here, so it pools every caller into one bucket of three sign-ins
113 // per ten seconds, and this suite signs in from 100+ files at once.
114 "-e",
115 "EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true",
111 options.image,116 options.image,
112 ];117 ];
113 log(options.logFile, `docker ${args.join(" ")}`);118 log(options.logFile, `docker ${args.join(" ")}`);