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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/selfhost-additional-trusted-origins.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions apps/docs/hosted/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ the container defaults.
| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
| `EXECUTOR_DB_PATH` | `<data dir>/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. |
Expand Down Expand Up @@ -91,6 +92,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
Expand Down
5 changes: 5 additions & 0 deletions apps/host-selfhost/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions apps/host-selfhost/src/auth/better-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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
Expand Down
42 changes: 33 additions & 9 deletions apps/host-selfhost/src/auth/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down Expand Up @@ -63,6 +65,27 @@ const SIGNUP_PATH = "/sign-up/email";

const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => {
const config = loadConfig();
// 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://"),
);
// 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] 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
// an explicitly-set env secret that is too weak.
const secret = config.authSecret;
Expand All @@ -88,16 +111,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
Expand Down
2 changes: 2 additions & 0 deletions apps/host-selfhost/src/auth/invalid-origin-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand All @@ -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");
});

Expand Down
7 changes: 4 additions & 3 deletions apps/host-selfhost/src/auth/invalid-origin-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion apps/host-selfhost/src/auth/origin-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)", () => {
Expand All @@ -60,3 +63,52 @@ 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("an empty or blank trusted-origins list leaves the canonical origin alone", () => {
resetOriginEnv();
process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
process.env.EXECUTOR_TRUSTED_ORIGINS = " , , ";
expect(loadConfig().trustedOrigins).toEqual(["https://executor.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/);
},
);
Loading
Loading