From 64b3c130a8fb34963ad15beda6de2c1ad047b8ff Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:24:53 -0700 Subject: [PATCH] Add a self-host opt-out for Better Auth rate limiting --- .changeset/selfhost-auth-rate-limit.md | 6 ++++++ apps/docs/hosted/docker.mdx | 1 + apps/host-selfhost/.env.example | 6 ++++++ apps/host-selfhost/src/auth/better-auth.ts | 4 ++++ apps/host-selfhost/src/config.ts | 10 ++++++++++ .../host-selfhost/src/executor-config.test.ts | 19 +++++++++++++++++++ e2e/setup/selfhost-docker.boot.ts | 5 +++++ 7 files changed, 51 insertions(+) create mode 100644 .changeset/selfhost-auth-rate-limit.md diff --git a/.changeset/selfhost-auth-rate-limit.md b/.changeset/selfhost-auth-rate-limit.md new file mode 100644 index 000000000..1324e5343 --- /dev/null +++ b/.changeset/selfhost-auth-rate-limit.md @@ -0,0 +1,6 @@ +--- +"@executor-js/host-selfhost": patch +"executor": patch +--- + +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. diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 8fc30f0e8..807951b07 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -69,6 +69,7 @@ the container defaults. | `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. | Tracing is configured separately, and off unless you turn it on — see [Tracing](/hosted/tracing). diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 1eb13376a..3e3296c88 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -36,6 +36,12 @@ # default — adversarial generated code should not reach your internal network. # 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. +# EXECUTOR_DISABLE_AUTH_RATE_LIMIT=false + # --- Local stdio MCP (trusted deployments only) ------------------------------- # Stdio MCP is disabled unless this is explicitly set to the exact string # "true". Enabling it lets users configure MCP servers whose commands execute diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 1d714312f..4031fdf27 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -130,6 +130,10 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: baseURL: config.webBaseUrl, trustedOrigins: [...config.trustedOrigins], advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, + // 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. + ...(config.authRateLimit ? {} : { rateLimit: { enabled: false } }), emailAndPassword: { enabled: true }, // `apiKey` issues long-lived personal keys (the API-keys page). With // `enableSessionForAPIKeys`, presenting a key resolves to its owner's diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index ab443f3bf..07dcc56d1 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -54,6 +54,15 @@ export interface SelfHostConfig { * internal network unless an operator opts in. */ readonly allowLocalNetwork: boolean; + /** + * 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`. + */ + readonly authRateLimit: boolean; // 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. @@ -187,6 +196,7 @@ export const loadConfig = (): SelfHostConfig => { webBaseUrl, trustedOrigins: resolveTrustedOrigins(webBaseUrl), allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", + authRateLimit: process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT !== "true", authSecret: resolveAuthSecret(), bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, diff --git a/apps/host-selfhost/src/executor-config.test.ts b/apps/host-selfhost/src/executor-config.test.ts index 313d097b8..576b93820 100644 --- a/apps/host-selfhost/src/executor-config.test.ts +++ b/apps/host-selfhost/src/executor-config.test.ts @@ -9,6 +9,8 @@ const TTL_ENV_NAME = "EXECUTOR_TOOLS_SYNC_TTL_MS"; const originalValue = process.env[ENV_NAME]; 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]; beforeEach(() => { process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret"; @@ -30,6 +32,11 @@ afterEach(() => { } else { process.env[TTL_ENV_NAME] = originalTtl; } + if (originalRateLimit === undefined) { + delete process.env[RATE_LIMIT_ENV_NAME]; + } else { + process.env[RATE_LIMIT_ENV_NAME] = originalRateLimit; + } }); const allowStdio = (): boolean => { @@ -112,3 +119,15 @@ test("a negative tools-sync TTL refuses to boot", () => { process.env[TTL_ENV_NAME] = "-1"; expect(() => loadConfig()).toThrow(/must not be negative/); }); + +test("auth rate limiting stays on unless the opt-out is exactly true", () => { + delete process.env[RATE_LIMIT_ENV_NAME]; + expect(loadConfig().authRateLimit).toBe(true); + process.env[RATE_LIMIT_ENV_NAME] = "TRUE"; + expect(loadConfig().authRateLimit).toBe(true); +}); + +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); +}); diff --git a/e2e/setup/selfhost-docker.boot.ts b/e2e/setup/selfhost-docker.boot.ts index 67f49d0fb..d8bcc27f9 100644 --- a/e2e/setup/selfhost-docker.boot.ts +++ b/e2e/setup/selfhost-docker.boot.ts @@ -108,6 +108,11 @@ export const runSelfhostContainer = async (options: RunContainerOptions): Promis // test servers and points the instance at them. "-e", "EXECUTOR_ALLOW_LOCAL_NETWORK=true", + // The production image runs Better Auth's rate limiter. It sees no proxy + // header here, so it pools every caller into one bucket of three sign-ins + // per ten seconds, and this suite signs in from 100+ files at once. + "-e", + "EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true", options.image, ]; log(options.logFile, `docker ${args.join(" ")}`);