History

feat(selfhost): allow additional trusted browser origins (#1441)

* feat(selfhost): allow additional trusted browser origins * fix(selfhost): support sessions on HTTP trusted origins * 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. ---------

Rusty Shackleford committed 2 months ago

Commit a9bf6231 parent d74865f9 files · +259 −14Browse files

Unified · Split

9 files
  1. .changeset/selfhost-additional-trusted-origins.md +10 −0
  2. apps/docs/hosted/docker.mdx +17 −0
  3. apps/host-selfhost/.env.example +5 −0
  4. apps/host-selfhost/src/auth/better-auth.test.ts +79 −0
  5. apps/host-selfhost/src/auth/better-auth.ts +33 −9
  6. apps/host-selfhost/src/auth/invalid-origin-help.test.ts +2 −0
  7. apps/host-selfhost/src/auth/invalid-origin-help.ts +4 −3
  8. apps/host-selfhost/src/auth/origin-resolution.test.ts +53 −1
  9. apps/host-selfhost/src/config.ts +56 −1
.changeset/selfhost-additional-trusted-origins.md+10−0
@@ -0,0 +1,10 @@
1---
2"executor": patch
3---
4
5Self-hosted instances can now allow additional browser origins without changing
6their canonical public URL. Set `EXECUTOR_TRUSTED_ORIGINS` to a comma-separated
7list of exact HTTP or HTTPS origins when one instance is intentionally reachable
8through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval
9links, and other generated URLs remain pinned to `EXECUTOR_WEB_BASE_URL`, and
10origins are never inferred from request headers.
apps/docs/hosted/docker.mdx+17−0
@@ -60,6 +60,7 @@ the container defaults. · 59 unmodified lines
60| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |60| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
61| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |61| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
62| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |62| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
63| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. |
63| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |64| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
64| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |65| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
65| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |66| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |
@@ -91,6 +92,22 @@ For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL · 25 unmodified lines
91and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser92and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser
92setup screen.93setup screen.
93 94
95### Additional browser aliases
96
97Keep `EXECUTOR_WEB_BASE_URL` pinned to the canonical public HTTPS origin used for
98OAuth callbacks and generated links. If the same instance is intentionally
99available through additional browser addresses, allow those exact origins
100separately:
101
102```bash
103-e EXECUTOR_WEB_BASE_URL=https://executor.example.com \
104-e EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788
105```
106
107Only cookie-authenticated browser requests use this allowlist. OAuth callbacks,
108MCP metadata, approval links, and other absolute URLs remain pinned to
109`EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers.
110
94## Connect an agent111## Connect an agent
95 112
96The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at113The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at
apps/host-selfhost/.env.example+5−0
@@ -8,6 +8,11 @@ · 7 unmodified lines
8# rejected. Behind a reverse proxy / TLS, set this to your public https URL.8# rejected. Behind a reverse proxy / TLS, set this to your public https URL.
9# EXECUTOR_WEB_BASE_URL=https://executor.example.com9# EXECUTOR_WEB_BASE_URL=https://executor.example.com
10 10
11# Additional browser origins allowed to authenticate with this instance, as a
12# comma-separated list. These aliases do not change OAuth callbacks or generated
13# links, which remain pinned to EXECUTOR_WEB_BASE_URL. Use exact origins only.
14# EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788
15
11# --- Session secret -----------------------------------------------------------16# --- Session secret -----------------------------------------------------------
12# Generated and persisted under the data volume on first boot if unset. Set this17# Generated and persisted under the data volume on first boot if unset. Set this
13# to manage it yourself (must be at least 32 characters). Rotating it signs every18# to manage it yourself (must be at least 32 characters). Rotating it signs every
apps/host-selfhost/src/auth/better-auth.test.ts+79−0
@@ -7,10 +7,16 @@ import { afterAll, expect, test } from "@effect/vitest"; · 6 unmodified lines
7import { mintInviteCode } from "../testing/mint-invite";7import { mintInviteCode } from "../testing/mint-invite";
8 8
9// Real Better Auth path: set a secret + bootstrap admin before importing.9// Real Better Auth path: set a secret + bootstrap admin before importing.
10// Better Auth skips origin checks in test mode by default; this suite exercises
11// the production check so the trusted-origin cases below cover the real path.
12process.env.NODE_ENV = "production";
13process.env.TEST = "false";
10process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-"));14process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-"));
11process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv";15process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv";
12process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local";16process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local";
13process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123";17process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123";
18process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
19process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788";
14 20
15const { makeSelfHostApiHandler } = await import("../app");21const { makeSelfHostApiHandler } = await import("../app");
16 22
@@ -19,6 +25,79 @@ afterAll(() => dispose()); · 2 unmodified lines
19 25
20const BASE = "http://localhost:4788";26const BASE = "http://localhost:4788";
21 27
28test("an HTTP trusted alias receives a usable session cookie with an HTTPS canonical URL", async () => {
29 const alias = "http://executor.home.arpa:4788";
30 const signIn = await handler(
31 new Request(`${alias}/api/auth/sign-in/email`, {
32 method: "POST",
33 headers: {
34 "content-type": "application/json",
35 origin: alias,
36 "sec-fetch-dest": "empty",
37 "sec-fetch-mode": "cors",
38 "sec-fetch-site": "same-origin",
39 },
40 body: JSON.stringify({
41 email: "admin@test.local",
42 password: "admin-password-123",
43 }),
44 }),
45 );
46 expect(signIn.status).toBe(200);
47 const sessionCookie = signIn.headers.get("set-cookie");
48 expect(sessionCookie).toContain("better-auth.session_token=");
49 expect(sessionCookie).not.toMatch(/(?:^|;\s*)Secure(?:;|$)/i);
50 expect(sessionCookie).not.toContain("__Secure-");
51});
52
53test("an explicitly trusted browser alias can sign up without changing the canonical base URL", async () => {
54 const alias = "http://executor.home.arpa:4788";
55 const inviteCode = await mintInviteCode(handler);
56 const signUp = await handler(
57 new Request(`${alias}/api/auth/sign-up/email`, {
58 method: "POST",
59 headers: {
60 "content-type": "application/json",
61 origin: alias,
62 "sec-fetch-dest": "empty",
63 "sec-fetch-mode": "cors",
64 "sec-fetch-site": "same-origin",
65 },
66 body: JSON.stringify({
67 email: "trusted-alias@test.local",
68 password: "member-password-123",
69 name: "Trusted Alias",
70 inviteCode,
71 }),
72 }),
73 );
74 expect(signUp.status).toBe(200);
75});
76
77test("an unlisted browser alias remains blocked", async () => {
78 const alias = "http://untrusted.home.arpa:4788";
79 const inviteCode = await mintInviteCode(handler);
80 const signUp = await handler(
81 new Request(`${alias}/api/auth/sign-up/email`, {
82 method: "POST",
83 headers: {
84 "content-type": "application/json",
85 origin: alias,
86 "sec-fetch-dest": "empty",
87 "sec-fetch-mode": "cors",
88 "sec-fetch-site": "same-origin",
89 },
90 body: JSON.stringify({
91 email: "untrusted-alias@test.local",
92 password: "member-password-123",
93 name: "Untrusted Alias",
94 inviteCode,
95 }),
96 }),
97 );
98 expect(signUp.status).toBe(403);
99});
100
22test("migrations create both the Better Auth and FumaDB executor schema regions", async () => {101test("migrations create both the Better Auth and FumaDB executor schema regions", async () => {
23 // Open a SEPARATE libSQL connection to the same file Better Auth (via its own102 // Open a SEPARATE libSQL connection to the same file Better Auth (via its own
24 // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this103 // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this
apps/host-selfhost/src/auth/better-auth.ts+33−9
@@ -25,6 +25,8 @@ interface SignupGate { · 24 unmodified lines
25// creation (the seed, or a future admin "add user") flows through other paths.25// creation (the seed, or a future admin "add user") flows through other paths.
26const SIGNUP_PATH = "/sign-up/email";26const SIGNUP_PATH = "/sign-up/email";
27 27
28let warnedInsecureTrustedOrigin = false;
29
28// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
29// Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor31// Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor
30// tables ("one connection, two schema regions").32// tables ("one connection, two schema regions").
@@ -63,6 +65,27 @@ const SIGNUP_PATH = "/sign-up/email"; · 32 unmodified lines
63 65
64const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => {66const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => {
65 const config = loadConfig();67 const config = loadConfig();
68 // A `Secure` session cookie is never sent back over plain HTTP, so an HTTP
69 // alias can sign in and then look signed out on every later request. Drop the
70 // attribute when ANY trusted origin is HTTP. This is not a new relaxation for
71 // the common cases: Better Auth already infers `useSecureCookies` from the
72 // baseURL scheme, so an all-HTTPS instance still gets `true` and the plain
73 // `http://localhost` default still gets `false`. It only changes the mixed
74 // case an operator opts into with EXECUTOR_TRUSTED_ORIGINS.
75 const hasInsecureTrustedOrigin = config.trustedOrigins.some((origin) =>
76 origin.startsWith("http://"),
77 );
78 // Warn only for that mixed case. An HTTP-only instance (local dev, a LAN
79 // deploy) never had Secure cookies to lose, and warning there would fire on
80 // every default boot.
81 const downgradesCanonicalCookies =
82 hasInsecureTrustedOrigin && config.webBaseUrl.startsWith("https://");
83 if (downgradesCanonicalCookies && !warnedInsecureTrustedOrigin) {
84 warnedInsecureTrustedOrigin = true;
85 console.warn(
86 "[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.",
87 );
88 }
66 // Always resolved (generated + persisted when no env is set); this guards only89 // Always resolved (generated + persisted when no env is set); this guards only
67 // an explicitly-set env secret that is too weak.90 // an explicitly-set env secret that is too weak.
68 const secret = config.authSecret;91 const secret = config.authSecret;
@@ -88,16 +111,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 19 unmodified lines
88 type: "sqlite" as const,111 type: "sqlite" as const,
89 },112 },
90 secret,113 secret,
91 // The browser Origin must match this exactly; CLI/MCP bearer requests carry114 // The canonical browser Origin is config.webBaseUrl; explicitly configured
92 // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit115 // aliases may also send cookie-authenticated requests. CLI/MCP bearer
93 // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/116 // requests carry no Origin and are unaffected. We deliberately do NOT derive
94 // …), else localhost — so a PaaS deploy is zero-config and any other host117 // either value from the request `Host`: matching the ecosystem (Windmill
95 // sets the one variable (a loud warning fires on the localhost fallback).118 // `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection
96 // See config.ts. We deliberately do NOT derive this from the request `Host`:119 // out of OAuth redirects and links. Additional trusted origins affect only
97 // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned120 // Better Auth's request validation; generated links and OAuth callbacks stay
98 // origin keeps host-header injection out of OAuth redirects and links.121 // pinned to config.webBaseUrl.
99 baseURL: config.webBaseUrl,122 baseURL: config.webBaseUrl,
100 trustedOrigins: [config.webBaseUrl],123 trustedOrigins: [...config.trustedOrigins],
124 advanced: { useSecureCookies: !hasInsecureTrustedOrigin },
101 emailAndPassword: { enabled: true },125 emailAndPassword: { enabled: true },
102 // `apiKey` issues long-lived personal keys (the API-keys page). With126 // `apiKey` issues long-lived personal keys (the API-keys page). With
103 // `enableSessionForAPIKeys`, presenting a key resolves to its owner's127 // `enableSessionForAPIKeys`, presenting a key resolves to its owner's
apps/host-selfhost/src/auth/invalid-origin-help.test.ts+2−0
@@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => { · 15 unmodified lines
16test("the help message names the URL to set", () => {16test("the help message names the URL to set", () => {
17 const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788");17 const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788");
18 expect(msg).toContain("EXECUTOR_WEB_BASE_URL");18 expect(msg).toContain("EXECUTOR_WEB_BASE_URL");
19 expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS");
19 expect(msg).toContain("https://app.example.com");20 expect(msg).toContain("https://app.example.com");
20 expect(msg).toContain("http://localhost:4788");21 expect(msg).toContain("http://localhost:4788");
21});22});
@@ -38,6 +39,7 @@ test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup messag · 16 unmodified lines
38 const body = (await rewritten!.json()) as { code: string; message: string };39 const body = (await rewritten!.json()) as { code: string; message: string };
39 expect(body.code).toBe("INVALID_ORIGIN");40 expect(body.code).toBe("INVALID_ORIGIN");
40 expect(body.message).toContain("EXECUTOR_WEB_BASE_URL");41 expect(body.message).toContain("EXECUTOR_WEB_BASE_URL");
42 expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS");
41 expect(body.message).toContain("https://app.example.com");43 expect(body.message).toContain("https://app.example.com");
42});44});
43 45
apps/host-selfhost/src/auth/invalid-origin-help.ts+4−3
@@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => { · 20 unmodified lines
21export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string =>21export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string =>
22 requestOrigin22 requestOrigin
23 ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` +23 ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` +
24 `Set the EXECUTOR_WEB_BASE_URL environment variable to ${requestOrigin} and restart the server, then try again. ` +24 `Set EXECUTOR_WEB_BASE_URL to ${requestOrigin} if it should be the canonical public URL, or add it to ` +
25 `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)`25 `EXECUTOR_TRUSTED_ORIGINS if it is an intentional browser alias, then restart the server. ` +
26 `(Railway, Render, Fly, Vercel, and similar hosts detect the canonical public URL automatically.)`
26 : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` +27 : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` +
27 `set EXECUTOR_WEB_BASE_URL to that address and restart the server.`;28 `set EXECUTOR_WEB_BASE_URL to that canonical address or add the alias to EXECUTOR_TRUSTED_ORIGINS, then restart the server.`;
28 29
29/**30/**
30 * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy31 * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy
apps/host-selfhost/src/auth/origin-resolution.test.ts+53−1
@@ -20,6 +20,7 @@ process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-")); · 19 unmodified lines
20// try/finally — these are the only env vars these cases read).20// try/finally — these are the only env vars these cases read).
21const PLATFORM_VARS = [21const PLATFORM_VARS = [
22 "EXECUTOR_WEB_BASE_URL",22 "EXECUTOR_WEB_BASE_URL",
23 "EXECUTOR_TRUSTED_ORIGINS",
23 "RAILWAY_PUBLIC_DOMAIN",24 "RAILWAY_PUBLIC_DOMAIN",
24 "RENDER_EXTERNAL_URL",25 "RENDER_EXTERNAL_URL",
25 "RENDER_EXTERNAL_HOSTNAME",26 "RENDER_EXTERNAL_HOSTNAME",
@@ -33,7 +34,9 @@ const resetOriginEnv = (): void => { · 7 unmodified lines
33 34
34test("webBaseUrl falls back to localhost with no public origin", () => {35test("webBaseUrl falls back to localhost with no public origin", () => {
35 resetOriginEnv();36 resetOriginEnv();
36 expect(loadConfig().webBaseUrl).toBe("http://localhost:4788");37 const config = loadConfig();
38 expect(config.webBaseUrl).toBe("http://localhost:4788");
39 expect(config.trustedOrigins).toEqual(["http://localhost:4788"]);
37});40});
38 41
39test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => {42test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => {
@@ -60,3 +63,52 @@ test("an explicit EXECUTOR_WEB_BASE_URL always wins over a platform var", () => · 20 unmodified lines
60 process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com";63 process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com";
61 expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com");64 expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com");
62});65});
66
67test("additional trusted origins are trimmed, normalized, and deduplicated", () => {
68 resetOriginEnv();
69 process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
70 process.env.EXECUTOR_TRUSTED_ORIGINS =
71 " http://executor.home.arpa:4788/, https://executor.example.com, http://192.0.2.10:4788 ";
72 expect(loadConfig().trustedOrigins).toEqual([
73 "https://executor.example.com",
74 "http://executor.home.arpa:4788",
75 "http://192.0.2.10:4788",
76 ]);
77});
78
79test("an empty or blank trusted-origins list leaves the canonical origin alone", () => {
80 resetOriginEnv();
81 process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
82 process.env.EXECUTOR_TRUSTED_ORIGINS = " , , ";
83 expect(loadConfig().trustedOrigins).toEqual(["https://executor.example.com"]);
84});
85
86// Every rejected shape is one an operator could plausibly type and then believe
87// was in force. A wildcard host is the dangerous one: accepting it as a literal
88// hostname would silently allow nothing while reading like it allows a whole
89// domain. A path/query/fragment reads like a scoped grant that origins cannot
90// express, and credentials in the URL are almost always a copy-paste mistake.
91test.each([
92 "https://executor.example.com/login",
93 "https://executor.example.com/?next=/",
94 "https://executor.example.com/#top",
95 "https://user:pass@executor.example.com",
96 "https://*.example.com",
97 "file:///tmp/executor",
98 "ftp://executor.example.com",
99])("a trusted origin that is not an exact http(s) origin (%s) refuses to boot", (raw) => {
100 resetOriginEnv();
101 process.env.EXECUTOR_TRUSTED_ORIGINS = raw;
102 expect(() => loadConfig()).toThrow(/exact http\(s\) origin/);
103});
104
105// A bare hostname is the most common typo, and it never parses as a URL at all,
106// so it gets the other message. Both name the variable.
107test.each(["executor.example.com", "//executor.example.com", "not a url"])(
108 "a trusted origin that is not a URL (%s) refuses to boot",
109 (raw) => {
110 resetOriginEnv();
111 process.env.EXECUTOR_TRUSTED_ORIGINS = raw;
112 expect(() => loadConfig()).toThrow(/EXECUTOR_TRUSTED_ORIGINS/);
113 },
114);
apps/host-selfhost/src/config.ts+56−1
@@ -26,6 +26,8 @@ export interface SelfHostConfig { · 25 unmodified lines
26 readonly dbPath: string;26 readonly dbPath: string;
27 /** Public base URL used by core tools that build absolute links. */27 /** Public base URL used by core tools that build absolute links. */
28 readonly webBaseUrl: string;28 readonly webBaseUrl: string;
29 /** Browser origins allowed to send cookie-authenticated requests. */
30 readonly trustedOrigins: readonly string[];
29 /**31 /**
30 * Whether sandboxed code may reach loopback/private network addresses.32 * Whether sandboxed code may reach loopback/private network addresses.
31 * Defaults to false — adversarial LLM code should not hit the host's33 * Defaults to false — adversarial LLM code should not hit the host's
@@ -155,11 +157,13 @@ const resolveWebBaseUrl = (port: number): string => { · 123 unmodified lines
155export const loadConfig = (): SelfHostConfig => {157export const loadConfig = (): SelfHostConfig => {
156 const port = Number.parseInt(process.env.PORT ?? "4788", 10);158 const port = Number.parseInt(process.env.PORT ?? "4788", 10);
157 const dataDir = resolveDataDir();159 const dataDir = resolveDataDir();
160 const webBaseUrl = resolveWebBaseUrl(port);
158 return {161 return {
159 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",162 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",
160 port,163 port,
161 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),164 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
162 webBaseUrl: resolveWebBaseUrl(port),165 webBaseUrl,
166 trustedOrigins: resolveTrustedOrigins(webBaseUrl),
163 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",167 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
164 authSecret: resolveAuthSecret(),168 authSecret: resolveAuthSecret(),
165 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,169 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
@@ -206,6 +210,57 @@ const resolveMcpSessionIdleTtlMs = (): number | undefined => { · 40 unmodified lines
206 return Math.floor(parsed);210 return Math.floor(parsed);
207};211};
208 212
213// EXECUTOR_TRUSTED_ORIGINS — extra browser origins allowed to send
214// cookie-authenticated requests when one instance is deliberately reachable
215// under more than one address (a LAN IP as well as a domain, say).
216//
217// This list widens ONLY Better Auth's origin/CSRF check. `webBaseUrl` stays the
218// single canonical origin for OAuth callbacks, MCP metadata, and every other
219// generated link, so an alias can never redirect a callback somewhere else.
220//
221// Entries must be exact origins. A path, query, fragment, credential, wildcard
222// host, or non-http(s) scheme is refused rather than trimmed off: an operator
223// who writes `https://*.example.com` means a pattern, and silently accepting it
224// as the literal host would leave them believing a wildcard is in force. Like
225// the other knobs here, a malformed value refuses to boot instead of quietly
226// leaving the browser locked out with an "Invalid origin" page.
227const normalizeTrustedOrigin = (value: string): string => {
228 if (!URL.canParse(value)) {
229 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
230 throw new Error(
231 `EXECUTOR_TRUSTED_ORIGINS contains ${JSON.stringify(value)}, which is not a valid URL origin`,
232 );
233 }
234 const url = new URL(value);
235 if (
236 (url.protocol !== "http:" && url.protocol !== "https:") ||
237 url.username.length > 0 ||
238 url.password.length > 0 ||
239 url.hostname.includes("*") ||
240 (url.pathname !== "" && url.pathname !== "/") ||
241 url.search.length > 0 ||
242 url.hash.length > 0
243 ) {
244 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
245 throw new Error(
246 `EXECUTOR_TRUSTED_ORIGINS entry ${JSON.stringify(value)} must be an exact http(s) origin (scheme, host, and optional port only)`,
247 );
248 }
249 return url.origin;
250};
251
252// The canonical origin always leads the list, so the unset case reproduces the
253// previous `[webBaseUrl]` exactly and an operator who repeats it in the env var
254// does not get a duplicate.
255const resolveTrustedOrigins = (webBaseUrl: string): readonly string[] => {
256 const additional = (process.env.EXECUTOR_TRUSTED_ORIGINS ?? "")
257 .split(",")
258 .map((value) => value.trim())
259 .filter((value) => value.length > 0)
260 .map(normalizeTrustedOrigin);
261 return [...new Set([webBaseUrl, ...additional])];
262};
263
209// The org slug doubles as a URL segment (`/<slug>/policies`), so an264// The org slug doubles as a URL segment (`/<slug>/policies`), so an
210// operator-set value must fit the shared grammar and avoid reserved root265// operator-set value must fit the shared grammar and avoid reserved root
211// segments (api, mcp, login, …) — a colliding slug would shadow real routes.266// segments (api, mcp, login, …) — a colliding slug would shadow real routes.