Skip to content

Commit a9bf623

Browse files
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. --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent d74865f commit a9bf623

9 files changed

Lines changed: 259 additions & 14 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

‎apps/docs/hosted/docker.mdx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ the container defaults.
6060
| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
6161
| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
6262
| `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. |
6364
| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
6465
| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
6566
| `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
9192
and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser
9293
setup screen.
9394

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+
94111
## Connect an agent
95112

96113
The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at

‎apps/host-selfhost/.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
# rejected. Behind a reverse proxy / TLS, set this to your public https URL.
99
# EXECUTOR_WEB_BASE_URL=https://executor.example.com
1010

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+
1116
# --- Session secret -----------------------------------------------------------
1217
# Generated and persisted under the data volume on first boot if unset. Set this
1318
# to manage it yourself (must be at least 32 characters). Rotating it signs every

‎apps/host-selfhost/src/auth/better-auth.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@ import { afterAll, expect, test } from "@effect/vitest";
77
import { mintInviteCode } from "../testing/mint-invite";
88

99
// 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";
1014
process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-"));
1115
process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv";
1216
process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local";
1317
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";
1420

1521
const { makeSelfHostApiHandler } = await import("../app");
1622

@@ -19,6 +25,79 @@ afterAll(() => dispose());
1925

2026
const BASE = "http://localhost:4788";
2127

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+
22101
test("migrations create both the Better Auth and FumaDB executor schema regions", async () => {
23102
// Open a SEPARATE libSQL connection to the same file Better Auth (via its own
24103
// LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this

‎apps/host-selfhost/src/auth/better-auth.ts‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ interface SignupGate {
2525
// creation (the seed, or a future admin "add user") flows through other paths.
2626
const SIGNUP_PATH = "/sign-up/email";
2727

28+
let warnedInsecureTrustedOrigin = false;
29+
2830
// ---------------------------------------------------------------------------
2931
// Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor
3032
// tables ("one connection, two schema regions").
@@ -63,6 +65,27 @@ const SIGNUP_PATH = "/sign-up/email";
6365

6466
const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => {
6567
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+
}
6689
// Always resolved (generated + persisted when no env is set); this guards only
6790
// an explicitly-set env secret that is too weak.
6891
const secret = config.authSecret;
@@ -88,16 +111,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
88111
type: "sqlite" as const,
89112
},
90113
secret,
91-
// The browser Origin must match this exactly; CLI/MCP bearer requests carry
92-
// no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit
93-
// EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/
94-
// …), else localhost — so a PaaS deploy is zero-config and any other host
95-
// sets the one variable (a loud warning fires on the localhost fallback).
96-
// See config.ts. We deliberately do NOT derive this from the request `Host`:
97-
// matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned
98-
// origin keeps host-header injection out of OAuth redirects and links.
114+
// The canonical browser Origin is config.webBaseUrl; explicitly configured
115+
// aliases may also send cookie-authenticated requests. CLI/MCP bearer
116+
// requests carry no Origin and are unaffected. We deliberately do NOT derive
117+
// either value from the request `Host`: matching the ecosystem (Windmill
118+
// `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection
119+
// out of OAuth redirects and links. Additional trusted origins affect only
120+
// Better Auth's request validation; generated links and OAuth callbacks stay
121+
// pinned to config.webBaseUrl.
99122
baseURL: config.webBaseUrl,
100-
trustedOrigins: [config.webBaseUrl],
123+
trustedOrigins: [...config.trustedOrigins],
124+
advanced: { useSecureCookies: !hasInsecureTrustedOrigin },
101125
emailAndPassword: { enabled: true },
102126
// `apiKey` issues long-lived personal keys (the API-keys page). With
103127
// `enableSessionForAPIKeys`, presenting a key resolves to its owner's

‎apps/host-selfhost/src/auth/invalid-origin-help.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => {
1616
test("the help message names the URL to set", () => {
1717
const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788");
1818
expect(msg).toContain("EXECUTOR_WEB_BASE_URL");
19+
expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS");
1920
expect(msg).toContain("https://app.example.com");
2021
expect(msg).toContain("http://localhost:4788");
2122
});
@@ -38,6 +39,7 @@ test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup messag
3839
const body = (await rewritten!.json()) as { code: string; message: string };
3940
expect(body.code).toBe("INVALID_ORIGIN");
4041
expect(body.message).toContain("EXECUTOR_WEB_BASE_URL");
42+
expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS");
4143
expect(body.message).toContain("https://app.example.com");
4244
});
4345

‎apps/host-selfhost/src/auth/invalid-origin-help.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => {
2121
export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string =>
2222
requestOrigin
2323
? `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. ` +
25-
`(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)`
24+
`Set EXECUTOR_WEB_BASE_URL to ${requestOrigin} if it should be the canonical public URL, or add it to ` +
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.)`
2627
: `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.`;
2829

2930
/**
3031
* If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy

‎apps/host-selfhost/src/auth/origin-resolution.test.ts‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-"));
2020
// try/finally — these are the only env vars these cases read).
2121
const PLATFORM_VARS = [
2222
"EXECUTOR_WEB_BASE_URL",
23+
"EXECUTOR_TRUSTED_ORIGINS",
2324
"RAILWAY_PUBLIC_DOMAIN",
2425
"RENDER_EXTERNAL_URL",
2526
"RENDER_EXTERNAL_HOSTNAME",
@@ -33,7 +34,9 @@ const resetOriginEnv = (): void => {
3334

3435
test("webBaseUrl falls back to localhost with no public origin", () => {
3536
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"]);
3740
});
3841

3942
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", () =>
6063
process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com";
6164
expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com");
6265
});
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+
);

0 commit comments

Comments
 (0)