Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/selfhost-auth-rate-limit.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/docs/hosted/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions apps/host-selfhost/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/host-selfhost/src/auth/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions apps/host-selfhost/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions apps/host-selfhost/src/executor-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 => {
Expand Down Expand Up @@ -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);
});
5 changes: 5 additions & 0 deletions e2e/setup/selfhost-docker.boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ")}`);
Expand Down
Loading