diff --git a/.changeset/selfhost-additional-trusted-origins.md b/.changeset/selfhost-additional-trusted-origins.md new file mode 100644 index 000000000..5e4a1851a --- /dev/null +++ b/.changeset/selfhost-additional-trusted-origins.md @@ -0,0 +1,10 @@ +--- +"executor": patch +--- + +Self-hosted instances can now allow additional browser origins without changing +their canonical public URL. Set `EXECUTOR_TRUSTED_ORIGINS` to a comma-separated +list of exact HTTP or HTTPS origins when one instance is intentionally reachable +through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval +links, and other generated URLs remain pinned to `EXECUTOR_WEB_BASE_URL`, and +origins are never inferred from request headers. diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 9de4d394b..2778bdbbe 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -60,6 +60,7 @@ the container defaults. | `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | | `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | | `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | +| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. | | `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | | `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | | `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | @@ -88,6 +89,22 @@ For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser setup screen. +### Additional browser aliases + +Keep `EXECUTOR_WEB_BASE_URL` pinned to the canonical public HTTPS origin used for +OAuth callbacks and generated links. If the same instance is intentionally +available through additional browser addresses, allow those exact origins +separately: + +```bash +-e EXECUTOR_WEB_BASE_URL=https://executor.example.com \ +-e EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788 +``` + +Only cookie-authenticated browser requests use this allowlist. OAuth callbacks, +MCP metadata, approval links, and other absolute URLs remain pinned to +`EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers. + ## Connect an agent The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 659751596..3d57c9a25 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -8,6 +8,11 @@ # rejected. Behind a reverse proxy / TLS, set this to your public https URL. # EXECUTOR_WEB_BASE_URL=https://executor.example.com +# Additional browser origins allowed to authenticate with this instance, as a +# comma-separated list. These aliases do not change OAuth callbacks or generated +# links, which remain pinned to EXECUTOR_WEB_BASE_URL. Use exact origins only. +# EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788 + # --- Session secret ----------------------------------------------------------- # Generated and persisted under the data volume on first boot if unset. Set this # to manage it yourself (must be at least 32 characters). Rotating it signs every diff --git a/apps/host-selfhost/src/auth/better-auth.test.ts b/apps/host-selfhost/src/auth/better-auth.test.ts index 968a6902d..e84a3131c 100644 --- a/apps/host-selfhost/src/auth/better-auth.test.ts +++ b/apps/host-selfhost/src/auth/better-auth.test.ts @@ -7,10 +7,16 @@ import { afterAll, expect, test } from "@effect/vitest"; import { mintInviteCode } from "../testing/mint-invite"; // Real Better Auth path: set a secret + bootstrap admin before importing. +// Better Auth skips origin checks in test mode by default; this suite exercises +// the production check so the trusted-origin cases below cover the real path. +process.env.NODE_ENV = "production"; +process.env.TEST = "false"; process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-")); process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; +process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com"; +process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788"; const { makeSelfHostApiHandler } = await import("../app"); @@ -19,6 +25,79 @@ afterAll(() => dispose()); const BASE = "http://localhost:4788"; +test("an HTTP trusted alias receives a usable session cookie with an HTTPS canonical URL", async () => { + const alias = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${alias}/api/auth/sign-in/email`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: alias, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }, + body: JSON.stringify({ + email: "admin@test.local", + password: "admin-password-123", + }), + }), + ); + expect(signIn.status).toBe(200); + const sessionCookie = signIn.headers.get("set-cookie"); + expect(sessionCookie).toContain("better-auth.session_token="); + expect(sessionCookie).not.toMatch(/(?:^|;\s*)Secure(?:;|$)/i); + expect(sessionCookie).not.toContain("__Secure-"); +}); + +test("an explicitly trusted browser alias can sign up without changing the canonical base URL", async () => { + const alias = "http://executor.home.arpa:4788"; + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${alias}/api/auth/sign-up/email`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: alias, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }, + body: JSON.stringify({ + email: "trusted-alias@test.local", + password: "member-password-123", + name: "Trusted Alias", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); +}); + +test("an unlisted browser alias remains blocked", async () => { + const alias = "http://untrusted.home.arpa:4788"; + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${alias}/api/auth/sign-up/email`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: alias, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }, + body: JSON.stringify({ + email: "untrusted-alias@test.local", + password: "member-password-123", + name: "Untrusted Alias", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(403); +}); + test("migrations create both the Better Auth and FumaDB executor schema regions", async () => { // Open a SEPARATE libSQL connection to the same file Better Auth (via its own // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 329c5a8e9..f779c4faa 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -25,6 +25,8 @@ interface SignupGate { // creation (the seed, or a future admin "add user") flows through other paths. const SIGNUP_PATH = "/sign-up/email"; +let warnedInsecureTrustedOrigin = false; + // --------------------------------------------------------------------------- // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor // tables ("one connection, two schema regions"). @@ -63,6 +65,15 @@ const SIGNUP_PATH = "/sign-up/email"; const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => { const config = loadConfig(); + const hasInsecureTrustedOrigin = config.trustedOrigins.some( + (origin) => new URL(origin).protocol === "http:", + ); + if (hasInsecureTrustedOrigin && !warnedInsecureTrustedOrigin) { + warnedInsecureTrustedOrigin = true; + console.warn( + "[executor] HTTP trusted origins require session cookies without the Secure attribute. Use HTTPS-only origins to keep session cookies transport-secure.", + ); + } // Always resolved (generated + persisted when no env is set); this guards only // an explicitly-set env secret that is too weak. const secret = config.authSecret; @@ -88,16 +99,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: type: "sqlite" as const, }, secret, - // The browser Origin must match this exactly; CLI/MCP bearer requests carry - // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit - // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/ - // …), else localhost — so a PaaS deploy is zero-config and any other host - // sets the one variable (a loud warning fires on the localhost fallback). - // See config.ts. We deliberately do NOT derive this from the request `Host`: - // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned - // origin keeps host-header injection out of OAuth redirects and links. + // The canonical browser Origin is config.webBaseUrl; explicitly configured + // aliases may also send cookie-authenticated requests. CLI/MCP bearer + // requests carry no Origin and are unaffected. We deliberately do NOT derive + // either value from the request `Host`: matching the ecosystem (Windmill + // `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection + // out of OAuth redirects and links. Additional trusted origins affect only + // Better Auth's request validation; generated links and OAuth callbacks stay + // pinned to config.webBaseUrl. baseURL: config.webBaseUrl, - trustedOrigins: [config.webBaseUrl], + trustedOrigins: [...config.trustedOrigins], + advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, 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/auth/invalid-origin-help.test.ts b/apps/host-selfhost/src/auth/invalid-origin-help.test.ts index e9cbae731..cdbefa7f3 100644 --- a/apps/host-selfhost/src/auth/invalid-origin-help.test.ts +++ b/apps/host-selfhost/src/auth/invalid-origin-help.test.ts @@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => { test("the help message names the URL to set", () => { const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788"); expect(msg).toContain("EXECUTOR_WEB_BASE_URL"); + expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS"); expect(msg).toContain("https://app.example.com"); expect(msg).toContain("http://localhost:4788"); }); @@ -38,6 +39,7 @@ test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup messag const body = (await rewritten!.json()) as { code: string; message: string }; expect(body.code).toBe("INVALID_ORIGIN"); expect(body.message).toContain("EXECUTOR_WEB_BASE_URL"); + expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS"); expect(body.message).toContain("https://app.example.com"); }); diff --git a/apps/host-selfhost/src/auth/invalid-origin-help.ts b/apps/host-selfhost/src/auth/invalid-origin-help.ts index f548b7902..de0df2f06 100644 --- a/apps/host-selfhost/src/auth/invalid-origin-help.ts +++ b/apps/host-selfhost/src/auth/invalid-origin-help.ts @@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => { export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string => requestOrigin ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` + - `Set the EXECUTOR_WEB_BASE_URL environment variable to ${requestOrigin} and restart the server, then try again. ` + - `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)` + `Set EXECUTOR_WEB_BASE_URL to ${requestOrigin} if it should be the canonical public URL, or add it to ` + + `EXECUTOR_TRUSTED_ORIGINS if it is an intentional browser alias, then restart the server. ` + + `(Railway, Render, Fly, Vercel, and similar hosts detect the canonical public URL automatically.)` : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` + - `set EXECUTOR_WEB_BASE_URL to that address and restart the server.`; + `set EXECUTOR_WEB_BASE_URL to that canonical address or add the alias to EXECUTOR_TRUSTED_ORIGINS, then restart the server.`; /** * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy diff --git a/apps/host-selfhost/src/auth/origin-resolution.test.ts b/apps/host-selfhost/src/auth/origin-resolution.test.ts index 95615464b..f9824cf26 100644 --- a/apps/host-selfhost/src/auth/origin-resolution.test.ts +++ b/apps/host-selfhost/src/auth/origin-resolution.test.ts @@ -20,6 +20,7 @@ process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-")); // try/finally — these are the only env vars these cases read). const PLATFORM_VARS = [ "EXECUTOR_WEB_BASE_URL", + "EXECUTOR_TRUSTED_ORIGINS", "RAILWAY_PUBLIC_DOMAIN", "RENDER_EXTERNAL_URL", "RENDER_EXTERNAL_HOSTNAME", @@ -33,7 +34,9 @@ const resetOriginEnv = (): void => { test("webBaseUrl falls back to localhost with no public origin", () => { resetOriginEnv(); - expect(loadConfig().webBaseUrl).toBe("http://localhost:4788"); + const config = loadConfig(); + expect(config.webBaseUrl).toBe("http://localhost:4788"); + expect(config.trustedOrigins).toEqual(["http://localhost:4788"]); }); test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => { @@ -60,3 +63,27 @@ test("an explicit EXECUTOR_WEB_BASE_URL always wins over a platform var", () => process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com"; expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com"); }); + +test("additional trusted origins are trimmed, normalized, and deduplicated", () => { + resetOriginEnv(); + process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com"; + process.env.EXECUTOR_TRUSTED_ORIGINS = + " http://executor.home.arpa:4788/, https://executor.example.com, http://192.0.2.10:4788 "; + expect(loadConfig().trustedOrigins).toEqual([ + "https://executor.example.com", + "http://executor.home.arpa:4788", + "http://192.0.2.10:4788", + ]); +}); + +test("additional trusted origins must be exact http(s) origins", () => { + resetOriginEnv(); + process.env.EXECUTOR_TRUSTED_ORIGINS = "https://executor.example.com/login"; + expect(() => loadConfig()).toThrow(/exact http\(s\) origin/); + + process.env.EXECUTOR_TRUSTED_ORIGINS = "file:///tmp/executor"; + expect(() => loadConfig()).toThrow(/exact http\(s\) origin/); + + process.env.EXECUTOR_TRUSTED_ORIGINS = "https://*.example.com"; + expect(() => loadConfig()).toThrow(/exact http\(s\) origin/); +}); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index e0cd282d5..921fba36a 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -26,6 +26,8 @@ export interface SelfHostConfig { readonly dbPath: string; /** Public base URL used by core tools that build absolute links. */ readonly webBaseUrl: string; + /** Browser origins allowed to send cookie-authenticated requests. */ + readonly trustedOrigins: readonly string[]; /** * Whether sandboxed code may reach loopback/private network addresses. * Defaults to false — adversarial LLM code should not hit the host's @@ -133,14 +135,50 @@ const resolveWebBaseUrl = (port: number): string => { return fallback; }; +const normalizeTrustedOrigin = (value: string): string => { + if (!URL.canParse(value)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: invalid operator configuration must fail at boot instead of silently weakening or breaking origin checks + throw new Error( + `EXECUTOR_TRUSTED_ORIGINS contains ${JSON.stringify(value)}, which is not a valid URL origin`, + ); + } + const url = new URL(value); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username.length > 0 || + url.password.length > 0 || + url.hostname.includes("*") || + (url.pathname !== "" && url.pathname !== "/") || + url.search.length > 0 || + url.hash.length > 0 + ) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: invalid operator configuration must fail at boot instead of silently weakening or breaking origin checks + throw new Error( + `EXECUTOR_TRUSTED_ORIGINS entry ${JSON.stringify(value)} must be an exact http(s) origin (scheme, host, and optional port only)`, + ); + } + return url.origin; +}; + +const resolveTrustedOrigins = (webBaseUrl: string): readonly string[] => { + const additional = (process.env.EXECUTOR_TRUSTED_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map(normalizeTrustedOrigin); + return [...new Set([webBaseUrl, ...additional])]; +}; + export const loadConfig = (): SelfHostConfig => { const port = Number.parseInt(process.env.PORT ?? "4788", 10); const dataDir = resolveDataDir(); + const webBaseUrl = resolveWebBaseUrl(port); return { host: process.env.EXECUTOR_HOST ?? "127.0.0.1", port, dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), - webBaseUrl: resolveWebBaseUrl(port), + webBaseUrl, + trustedOrigins: resolveTrustedOrigins(webBaseUrl), allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", authSecret: resolveAuthSecret(), bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,