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+
6+Add `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
6969 | `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. |
7070 | `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. |
7171 | `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. |
7273
7374 Tracing is configured separately, and off unless you turn it on — see
7475 [Tracing](/hosted/tracing).
apps/host-selfhost/.env.example+6−0
@@ -36,6 +36,12 @@ · 35 unmodified lines
3636 # default — adversarial generated code should not reach your internal network.
3737 # EXECUTOR_ALLOW_LOCAL_NETWORK=false
3838
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+
3945 # --- Local stdio MCP (trusted deployments only) -------------------------------
4046 # Stdio MCP is disabled unless this is explicitly set to the exact string
4147 # "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
130130 baseURL: config.webBaseUrl,
131131 trustedOrigins: [...config.trustedOrigins],
132132 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 } }),
133137 emailAndPassword: { enabled: true },
134138 // `apiKey` issues long-lived personal keys (the API-keys page). With
135139 // `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
5454 * internal network unless an operator opts in.
5555 */
5656 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;
5766 // Better Auth session secret. Always resolved (env, else generated + persisted
5867 // under the data dir) so a single-container deploy boots with no env; the auth
5968 // layer still validates an explicitly-set env secret is long enough.
@@ -187,6 +196,7 @@ export const loadConfig = (): SelfHostConfig => { · 127 unmodified lines
187196 webBaseUrl,
188197 trustedOrigins: resolveTrustedOrigins(webBaseUrl),
189198 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
199+ authRateLimit: process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT !== "true",
190200 authSecret: resolveAuthSecret(),
191201 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
192202 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
99 const originalValue = process.env[ENV_NAME];
1010 const originalSecret = process.env[SECRET_ENV_NAME];
1111 const originalTtl = process.env[TTL_ENV_NAME];
12+const RATE_LIMIT_ENV_NAME = "EXECUTOR_DISABLE_AUTH_RATE_LIMIT";
13+const originalRateLimit = process.env[RATE_LIMIT_ENV_NAME];
1214
1315 beforeEach(() => {
1416 process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret";
@@ -30,6 +32,11 @@ afterEach(() => { · 15 unmodified lines
3032 } else {
3133 process.env[TTL_ENV_NAME] = originalTtl;
3234 }
35+ if (originalRateLimit === undefined) {
36+ delete process.env[RATE_LIMIT_ENV_NAME];
37+ } else {
38+ process.env[RATE_LIMIT_ENV_NAME] = originalRateLimit;
39+ }
3340 });
3441
3542 const allowStdio = (): boolean => {
@@ -112,3 +119,15 @@ test("a negative tools-sync TTL refuses to boot", () => { · 76 unmodified lines
112119 process.env[TTL_ENV_NAME] = "-1";
113120 expect(() => loadConfig()).toThrow(/must not be negative/);
114121 });
122+
123+test("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+
130+test("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
108108 // test servers and points the instance at them.
109109 "-e",
110110 "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",
111116 options.image,
112117 ];
113118 log(options.logFile, `docker ${args.join(" ")}`);