From a0f048dc17214b45cd7aa93a8b8624fb4f1c0e16 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:17:36 -0700 Subject: [PATCH 1/3] feat(selfhost): allow additional trusted browser origins --- .../selfhost-additional-trusted-origins.md | 10 ++++ apps/docs/hosted/docker.mdx | 17 ++++++ apps/host-selfhost/.env.example | 5 ++ .../src/auth/better-auth.test.ts | 54 +++++++++++++++++++ apps/host-selfhost/src/auth/better-auth.ts | 18 +++---- .../src/auth/invalid-origin-help.test.ts | 2 + .../src/auth/invalid-origin-help.ts | 7 +-- .../src/auth/origin-resolution.test.ts | 29 +++++++++- apps/host-selfhost/src/config.ts | 40 +++++++++++++- 9 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 .changeset/selfhost-additional-trusted-origins.md diff --git a/.changeset/selfhost-additional-trusted-origins.md b/.changeset/selfhost-additional-trusted-origins.md new file mode 100644 index 0000000000..5e4a1851a0 --- /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 9de4d394ba..2778bdbbef 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 6597515969..3d57c9a258 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 968a6902d0..e09dec6ec2 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 = "http://localhost:4788"; +process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788"; const { makeSelfHostApiHandler } = await import("../app"); @@ -19,6 +25,54 @@ afterAll(() => dispose()); const BASE = "http://localhost:4788"; +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 329c5a8e96..a4b3656933 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -88,16 +88,16 @@ 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], 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 e9cbae7319..cdbefa7f34 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 f548b7902d..de0df2f065 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 95615464ba..f9824cf261 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 e0cd282d52..921fba36ab 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, From d2dc7296a832b6db67b75e97bacdc238b96ecba4 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:36:51 -0700 Subject: [PATCH 2/3] fix(selfhost): support sessions on HTTP trusted origins --- .../src/auth/better-auth.test.ts | 27 ++++++++++++++++++- apps/host-selfhost/src/auth/better-auth.ts | 12 +++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/host-selfhost/src/auth/better-auth.test.ts b/apps/host-selfhost/src/auth/better-auth.test.ts index e09dec6ec2..e84a3131ca 100644 --- a/apps/host-selfhost/src/auth/better-auth.test.ts +++ b/apps/host-selfhost/src/auth/better-auth.test.ts @@ -15,7 +15,7 @@ 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 = "http://localhost:4788"; +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"); @@ -25,6 +25,31 @@ 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); diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index a4b3656933..f779c4faa6 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; @@ -98,6 +109,7 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: // pinned to config.webBaseUrl. baseURL: 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 From 5aa51225d59bd4b4d992491509d639fc2dedbbf2 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:14:29 -0700 Subject: [PATCH 3/3] Scope the insecure-origin warning and document the trusted-origins knob The Secure-cookie warning fired whenever any trusted origin was http, which includes the plain http://localhost default, so every local boot printed it. Warn only for the mixed case an operator opts into: an http alias alongside an https canonical URL, where the canonical origin really does lose Secure cookies. Move the resolver below loadConfig with the other env knobs and state why each rejected origin shape is refused rather than trimmed. Cover the rejected shapes as a table, plus the blank-list and bare-hostname cases. --- apps/host-selfhost/src/auth/better-auth.ts | 20 ++++- .../src/auth/origin-resolution.test.ts | 39 +++++++-- apps/host-selfhost/src/config.ts | 85 +++++++++++-------- 3 files changed, 99 insertions(+), 45 deletions(-) diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 3c9a0badcf..57d8eac86e 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -65,13 +65,25 @@ let warnedInsecureTrustedOrigin = false; const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => { const config = loadConfig(); - const hasInsecureTrustedOrigin = config.trustedOrigins.some( - (origin) => new URL(origin).protocol === "http:", + // A `Secure` session cookie is never sent back over plain HTTP, so an HTTP + // alias can sign in and then look signed out on every later request. Drop the + // attribute when ANY trusted origin is HTTP. This is not a new relaxation for + // the common cases: Better Auth already infers `useSecureCookies` from the + // baseURL scheme, so an all-HTTPS instance still gets `true` and the plain + // `http://localhost` default still gets `false`. It only changes the mixed + // case an operator opts into with EXECUTOR_TRUSTED_ORIGINS. + const hasInsecureTrustedOrigin = config.trustedOrigins.some((origin) => + origin.startsWith("http://"), ); - if (hasInsecureTrustedOrigin && !warnedInsecureTrustedOrigin) { + // Warn only for that mixed case. An HTTP-only instance (local dev, a LAN + // deploy) never had Secure cookies to lose, and warning there would fire on + // every default boot. + const downgradesCanonicalCookies = + hasInsecureTrustedOrigin && config.webBaseUrl.startsWith("https://"); + if (downgradesCanonicalCookies && !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.", + "[executor] EXECUTOR_TRUSTED_ORIGINS contains an http:// origin, so session cookies drop the Secure attribute for every origin — including the https:// canonical URL. Use https:// aliases to keep session cookies transport-secure.", ); } // Always resolved (generated + persisted when no env is set); this guards only diff --git a/apps/host-selfhost/src/auth/origin-resolution.test.ts b/apps/host-selfhost/src/auth/origin-resolution.test.ts index f9824cf261..52cfd3ee4b 100644 --- a/apps/host-selfhost/src/auth/origin-resolution.test.ts +++ b/apps/host-selfhost/src/auth/origin-resolution.test.ts @@ -76,14 +76,39 @@ test("additional trusted origins are trimmed, normalized, and deduplicated", () ]); }); -test("additional trusted origins must be exact http(s) origins", () => { +test("an empty or blank trusted-origins list leaves the canonical origin alone", () => { 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_WEB_BASE_URL = "https://executor.example.com"; + process.env.EXECUTOR_TRUSTED_ORIGINS = " , , "; + expect(loadConfig().trustedOrigins).toEqual(["https://executor.example.com"]); +}); - process.env.EXECUTOR_TRUSTED_ORIGINS = "https://*.example.com"; +// Every rejected shape is one an operator could plausibly type and then believe +// was in force. A wildcard host is the dangerous one: accepting it as a literal +// hostname would silently allow nothing while reading like it allows a whole +// domain. A path/query/fragment reads like a scoped grant that origins cannot +// express, and credentials in the URL are almost always a copy-paste mistake. +test.each([ + "https://executor.example.com/login", + "https://executor.example.com/?next=/", + "https://executor.example.com/#top", + "https://user:pass@executor.example.com", + "https://*.example.com", + "file:///tmp/executor", + "ftp://executor.example.com", +])("a trusted origin that is not an exact http(s) origin (%s) refuses to boot", (raw) => { + resetOriginEnv(); + process.env.EXECUTOR_TRUSTED_ORIGINS = raw; expect(() => loadConfig()).toThrow(/exact http\(s\) origin/); }); + +// A bare hostname is the most common typo, and it never parses as a URL at all, +// so it gets the other message. Both name the variable. +test.each(["executor.example.com", "//executor.example.com", "not a url"])( + "a trusted origin that is not a URL (%s) refuses to boot", + (raw) => { + resetOriginEnv(); + process.env.EXECUTOR_TRUSTED_ORIGINS = raw; + expect(() => loadConfig()).toThrow(/EXECUTOR_TRUSTED_ORIGINS/); + }, +); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 9408510fe7..9e864f1c94 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -154,40 +154,6 @@ 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(); @@ -244,6 +210,57 @@ const resolveMcpSessionIdleTtlMs = (): number | undefined => { return Math.floor(parsed); }; +// EXECUTOR_TRUSTED_ORIGINS — extra browser origins allowed to send +// cookie-authenticated requests when one instance is deliberately reachable +// under more than one address (a LAN IP as well as a domain, say). +// +// This list widens ONLY Better Auth's origin/CSRF check. `webBaseUrl` stays the +// single canonical origin for OAuth callbacks, MCP metadata, and every other +// generated link, so an alias can never redirect a callback somewhere else. +// +// Entries must be exact origins. A path, query, fragment, credential, wildcard +// host, or non-http(s) scheme is refused rather than trimmed off: an operator +// who writes `https://*.example.com` means a pattern, and silently accepting it +// as the literal host would leave them believing a wildcard is in force. Like +// the other knobs here, a malformed value refuses to boot instead of quietly +// leaving the browser locked out with an "Invalid origin" page. +const normalizeTrustedOrigin = (value: string): string => { + if (!URL.canParse(value)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + 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: refuse to boot on a malformed operator knob + 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; +}; + +// The canonical origin always leads the list, so the unset case reproduces the +// previous `[webBaseUrl]` exactly and an operator who repeats it in the env var +// does not get a duplicate. +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])]; +}; + // The org slug doubles as a URL segment (`//policies`), so an // operator-set value must fit the shared grammar and avoid reserved root // segments (api, mcp, login, …) — a colliding slug would shadow real routes.