Unified · Split
| @@ -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. | |
| @@ -69,6 +69,7 @@ the container defaults. · 68 unmodified lines | ||
| 69 | 69 | | `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | |
| 70 | 70 | | `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | |
| 71 | 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 | |
| 73 | 74 | Tracing is configured separately, and off unless you turn it on — see |
| 74 | 75 | [Tracing](/hosted/tracing). |
| @@ -36,6 +36,12 @@ · 35 unmodified lines | ||
| 36 | 36 | # default — adversarial generated code should not reach your internal network. |
| 37 | 37 | # 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 | 45 | # --- Local stdio MCP (trusted deployments only) ------------------------------- |
| 40 | 46 | # Stdio MCP is disabled unless this is explicitly set to the exact string |
| 41 | 47 | # "true". Enabling it lets users configure MCP servers whose commands execute |
| @@ -130,6 +130,10 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 129 unmodified lines | ||
| 130 | 130 | baseURL: config.webBaseUrl, |
| 131 | 131 | trustedOrigins: [...config.trustedOrigins], |
| 132 | 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 | 137 | emailAndPassword: { enabled: true }, |
| 134 | 138 | // `apiKey` issues long-lived personal keys (the API-keys page). With |
| 135 | 139 | // `enableSessionForAPIKeys`, presenting a key resolves to its owner's |
| @@ -54,6 +54,15 @@ export interface SelfHostConfig { · 53 unmodified lines | ||
| 54 | 54 | * internal network unless an operator opts in. |
| 55 | 55 | */ |
| 56 | 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 | 66 | // Better Auth session secret. Always resolved (env, else generated + persisted |
| 58 | 67 | // under the data dir) so a single-container deploy boots with no env; the auth |
| 59 | 68 | // layer still validates an explicitly-set env secret is long enough. |
| @@ -187,6 +196,7 @@ export const loadConfig = (): SelfHostConfig => { · 127 unmodified lines | ||
| 187 | 196 | webBaseUrl, |
| 188 | 197 | trustedOrigins: resolveTrustedOrigins(webBaseUrl), |
| 189 | 198 | allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", |
| 199 | + authRateLimit: process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT !== "true", | |
| 190 | 200 | authSecret: resolveAuthSecret(), |
| 191 | 201 | bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, |
| 192 | 202 | bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, |
| @@ -9,6 +9,8 @@ const TTL_ENV_NAME = "EXECUTOR_TOOLS_SYNC_TTL_MS"; · 8 unmodified lines | ||
| 9 | 9 | const originalValue = process.env[ENV_NAME]; |
| 10 | 10 | const originalSecret = process.env[SECRET_ENV_NAME]; |
| 11 | 11 | 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]; | |
| 12 | 14 | |
| 13 | 15 | beforeEach(() => { |
| 14 | 16 | process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret"; |
| @@ -30,6 +32,11 @@ afterEach(() => { · 15 unmodified lines | ||
| 30 | 32 | } else { |
| 31 | 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 | |
| 35 | 42 | const allowStdio = (): boolean => { |
| @@ -112,3 +119,15 @@ test("a negative tools-sync TTL refuses to boot", () => { · 76 unmodified lines | ||
| 112 | 119 | process.env[TTL_ENV_NAME] = "-1"; |
| 113 | 120 | expect(() => loadConfig()).toThrow(/must not be negative/); |
| 114 | 121 | }); |
| 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 | +}); | |
| @@ -108,6 +108,11 @@ export const runSelfhostContainer = async (options: RunContainerOptions): Promis · 107 unmodified lines | ||
| 108 | 108 | // test servers and points the instance at them. |
| 109 | 109 | "-e", |
| 110 | 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 | 116 | options.image, |
| 112 | 117 | ]; |
| 113 | 118 | log(options.logFile, `docker ${args.join(" ")}`); |