Skip to content
Open
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 @@ -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
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
30 changes: 21 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,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;
Expand All @@ -88,16 +99,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
type: "sqlite" as const,
},
secret,
// The browser Origin must match this exactly; CLI/MCP bearer requests carry
// no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit
// EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/
// …), else localhost — so a PaaS deploy is zero-config and any other host
// sets the one variable (a loud warning fires on the localhost fallback).
// See config.ts. We deliberately do NOT derive this from the request `Host`:
// matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned
// origin keeps host-header injection out of OAuth redirects and links.
// The canonical browser Origin is config.webBaseUrl; explicitly configured
// aliases may also send cookie-authenticated requests. CLI/MCP bearer
// requests carry no Origin and are unaffected. We deliberately do NOT derive
// either value from the request `Host`: matching the ecosystem (Windmill
// `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection
// out of OAuth redirects and links. Additional trusted origins affect only
// Better Auth's request validation; generated links and OAuth callbacks stay
// pinned to config.webBaseUrl.
baseURL: config.webBaseUrl,
trustedOrigins: [config.webBaseUrl],
trustedOrigins: [...config.trustedOrigins],
advanced: { useSecureCookies: !hasInsecureTrustedOrigin },
emailAndPassword: { enabled: true },
// `apiKey` issues long-lived personal keys (the API-keys page). With
// `enableSessionForAPIKeys`, presenting a key resolves to its owner's
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
29 changes: 28 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,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/);
});
40 changes: 39 additions & 1 deletion apps/host-selfhost/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down