* 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. ---------
Unified · Split
| @@ -0,0 +1,10 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Self-hosted instances can now allow additional browser origins without changing | ||
| 6 | their canonical public URL. Set `EXECUTOR_TRUSTED_ORIGINS` to a comma-separated | ||
| 7 | list of exact HTTP or HTTPS origins when one instance is intentionally reachable | ||
| 8 | through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval | ||
| 9 | links, and other generated URLs remain pinned to `EXECUTOR_WEB_BASE_URL`, and | ||
| 10 | origins are never inferred from request headers. | ||
| @@ -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 | |||
| 91 | and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser | 92 | and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser |
| 92 | setup screen. | 93 | setup screen. |
| 93 | 94 | ||
| 95 | ### Additional browser aliases | ||
| 96 | |||
| 97 | Keep `EXECUTOR_WEB_BASE_URL` pinned to the canonical public HTTPS origin used for | ||
| 98 | OAuth callbacks and generated links. If the same instance is intentionally | ||
| 99 | available through additional browser addresses, allow those exact origins | ||
| 100 | separately: | ||
| 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 | |||
| 107 | Only cookie-authenticated browser requests use this allowlist. OAuth callbacks, | ||
| 108 | MCP 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 agent | 111 | ## Connect an agent |
| 95 | 112 | ||
| 96 | The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at | 113 | The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at |
| @@ -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.com | 9 | # 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 this | 17 | # 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 every | 18 | # to manage it yourself (must be at least 32 characters). Rotating it signs every |
| @@ -7,10 +7,16 @@ import { afterAll, expect, test } from "@effect/vitest"; · 6 unmodified lines | |||
| 7 | import { mintInviteCode } from "../testing/mint-invite"; | 7 | import { 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. | ||
| 12 | process.env.NODE_ENV = "production"; | ||
| 13 | process.env.TEST = "false"; | ||
| 10 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-")); | 14 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-")); |
| 11 | process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; | 15 | process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; |
| 12 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; | 16 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; |
| 13 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; | 17 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; |
| 18 | process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com"; | ||
| 19 | process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788"; | ||
| 14 | 20 | ||
| 15 | const { makeSelfHostApiHandler } = await import("../app"); | 21 | const { makeSelfHostApiHandler } = await import("../app"); |
| 16 | 22 | ||
| @@ -19,6 +25,79 @@ afterAll(() => dispose()); · 2 unmodified lines | |||
| 19 | 25 | ||
| 20 | const BASE = "http://localhost:4788"; | 26 | const BASE = "http://localhost:4788"; |
| 21 | 27 | ||
| 28 | test("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 | |||
| 53 | test("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 | |||
| 77 | test("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 | |||
| 22 | test("migrations create both the Better Auth and FumaDB executor schema regions", async () => { | 101 | test("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 own | 102 | // 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 this | 103 | // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this |
| @@ -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. |
| 26 | const SIGNUP_PATH = "/sign-up/email"; | 26 | const SIGNUP_PATH = "/sign-up/email"; |
| 27 | 27 | ||
| 28 | let warnedInsecureTrustedOrigin = false; | ||
| 29 | |||
| 28 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
| 29 | // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor | 31 | // 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 | ||
| 64 | const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => { | 66 | const 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 only | 89 | // 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 carry | 114 | // The canonical browser Origin is config.webBaseUrl; explicitly configured |
| 92 | // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit | 115 | // 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 host | 117 | // 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 pinned | 120 | // 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). With | 126 | // `apiKey` issues long-lived personal keys (the API-keys page). With |
| 103 | // `enableSessionForAPIKeys`, presenting a key resolves to its owner's | 127 | // `enableSessionForAPIKeys`, presenting a key resolves to its owner's |
| @@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => { · 15 unmodified lines | |||
| 16 | test("the help message names the URL to set", () => { | 16 | test("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 | ||
| @@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => { · 20 unmodified lines | |||
| 21 | export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string => | 21 | export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string => |
| 22 | requestOrigin | 22 | 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 copy | 31 | * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy |
| @@ -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). |
| 21 | const PLATFORM_VARS = [ | 21 | const 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 | ||
| 34 | test("webBaseUrl falls back to localhost with no public origin", () => { | 35 | test("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 | ||
| 39 | test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => { | 42 | test("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 | |||
| 67 | test("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 | |||
| 79 | test("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. | ||
| 91 | test.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. | ||
| 107 | test.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 | ); | ||
| @@ -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's | 33 | * Defaults to false — adversarial LLM code should not hit the host's |
| @@ -155,11 +157,13 @@ const resolveWebBaseUrl = (port: number): string => { · 123 unmodified lines | |||
| 155 | export const loadConfig = (): SelfHostConfig => { | 157 | export 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. | ||
| 227 | const 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. | ||
| 255 | const 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 an | 264 | // The org slug doubles as a URL segment (`/<slug>/policies`), so an |
| 210 | // operator-set value must fit the shared grammar and avoid reserved root | 265 | // 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. |