From e423f08d5fac819c69ed7dfe4c30b125af9a20d8 Mon Sep 17 00:00:00 2001 From: E Jikan <94772817+vstreame@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:25:09 +0900 Subject: [PATCH] Allow exact local origins while local network access stays off Self-host operators can list exact http(s)://: origins in EXECUTOR_ALLOWED_LOCAL_ORIGINS. The hosted HTTP client then lets those through while EXECUTOR_ALLOW_LOCAL_NETWORK=false keeps every other loopback and private address blocked. Entries must be IP literals, so DNS never decides; metadata addresses are refused; redirects are re-checked per hop. --- .changeset/allowed-local-origins.md | 5 ++ apps/docs/hosted/docker.mdx | 1 + apps/host-selfhost/src/config.ts | 26 ++++++ apps/host-selfhost/src/execution.ts | 1 + .../host-selfhost/src/executor-config.test.ts | 30 +++++++ .../core/api/src/server/scoped-executor.ts | 8 ++ packages/core/sdk/src/host-internal.ts | 1 + .../core/sdk/src/hosted-http-client.test.ts | 81 +++++++++++++++++++ packages/core/sdk/src/hosted-http-client.ts | 54 +++++++++++-- 9 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 .changeset/allowed-local-origins.md diff --git a/.changeset/allowed-local-origins.md b/.changeset/allowed-local-origins.md new file mode 100644 index 0000000000..c691e3dbf2 --- /dev/null +++ b/.changeset/allowed-local-origins.md @@ -0,0 +1,5 @@ +--- +"executor": minor +--- + +Self-host can now allow a few exact local origins while `EXECUTOR_ALLOW_LOCAL_NETWORK` stays off. Set `EXECUTOR_ALLOWED_LOCAL_ORIGINS` to comma-separated `http(s)://:` origins, such as a loopback API on the same host. Each entry must be an IP literal, so DNS never decides. Metadata addresses are refused, and every redirect is still checked. Other loopback and private addresses stay blocked. diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 807951b07f..06a5a7c4ef 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -69,6 +69,7 @@ the container defaults. | `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | | `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | | `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | +| `EXECUTOR_ALLOWED_LOCAL_ORIGINS` | unset | Comma-separated `http(s)://:` origins reachable even with local network off. | | `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. | Tracing is configured separately, and off unless you turn it on — see diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 07dcc56d14..29c43f4f57 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { isValidOrgSlug } from "@executor-js/api"; +import { normalizeAllowedLocalOrigin } from "@executor-js/sdk/host-internal"; import { missingPublicOriginWarning, resolvePublicOrigin, @@ -54,6 +55,12 @@ export interface SelfHostConfig { * internal network unless an operator opts in. */ readonly allowLocalNetwork: boolean; + /** + * Exact local/private origins outbound requests may reach even with + * `allowLocalNetwork` off, from `EXECUTOR_ALLOWED_LOCAL_ORIGINS` + * (comma-separated `http(s)://:`). + */ + readonly allowedLocalOrigins: readonly string[]; /** * Whether Better Auth rate-limits its own endpoints (sign-in and friends). * Better Auth turns this on in production and keys the limit on the client @@ -196,6 +203,7 @@ export const loadConfig = (): SelfHostConfig => { webBaseUrl, trustedOrigins: resolveTrustedOrigins(webBaseUrl), allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", + allowedLocalOrigins: resolveAllowedLocalOrigins(), authRateLimit: process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT !== "true", authSecret: resolveAuthSecret(), bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, @@ -341,6 +349,24 @@ const normalizeTrustedOrigin = (value: string): string => { // The canonical origin always leads the list, so the unset case reproduces the // previous `[webBaseUrl]` exactly and an operator who repeats it in the env var // does not get a duplicate. +const resolveAllowedLocalOrigins = (): readonly string[] => { + const entries = (process.env.EXECUTOR_ALLOWED_LOCAL_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const origins = entries.map((entry) => { + const origin = normalizeAllowedLocalOrigin(entry); + if (origin === null) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_ALLOWED_LOCAL_ORIGINS entry ${JSON.stringify(entry)} is not an http(s)://[:port] origin`, + ); + } + return origin; + }); + return [...new Set(origins)]; +}; + const resolveTrustedOrigins = (webBaseUrl: string): readonly string[] => { const additional = (process.env.EXECUTOR_TRUSTED_ORIGINS ?? "") .split(",") diff --git a/apps/host-selfhost/src/execution.ts b/apps/host-selfhost/src/execution.ts index 8dab577b93..52c7bc8825 100644 --- a/apps/host-selfhost/src/execution.ts +++ b/apps/host-selfhost/src/execution.ts @@ -53,6 +53,7 @@ export const SelfHostHostConfig: Layer.Layer = Layer.sync(HostConfig const config = loadConfig(); return { allowLocalNetwork: config.allowLocalNetwork, + allowedLocalOrigins: config.allowedLocalOrigins, webBaseUrl: config.webBaseUrl, oauthCallbackPath: "/api/oauth/callback", toolsSyncTtlMs: config.toolsSyncTtlMs, diff --git a/apps/host-selfhost/src/executor-config.test.ts b/apps/host-selfhost/src/executor-config.test.ts index 576b93820c..15a6535ba1 100644 --- a/apps/host-selfhost/src/executor-config.test.ts +++ b/apps/host-selfhost/src/executor-config.test.ts @@ -11,6 +11,8 @@ const originalSecret = process.env[SECRET_ENV_NAME]; const originalTtl = process.env[TTL_ENV_NAME]; const RATE_LIMIT_ENV_NAME = "EXECUTOR_DISABLE_AUTH_RATE_LIMIT"; const originalRateLimit = process.env[RATE_LIMIT_ENV_NAME]; +const ORIGINS_ENV_NAME = "EXECUTOR_ALLOWED_LOCAL_ORIGINS"; +const originalOrigins = process.env[ORIGINS_ENV_NAME]; beforeEach(() => { process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret"; @@ -37,6 +39,11 @@ afterEach(() => { } else { process.env[RATE_LIMIT_ENV_NAME] = originalRateLimit; } + if (originalOrigins === undefined) { + delete process.env[ORIGINS_ENV_NAME]; + } else { + process.env[ORIGINS_ENV_NAME] = originalOrigins; + } }); const allowStdio = (): boolean => { @@ -131,3 +138,26 @@ test("auth rate limiting is off when the opt-out is exactly true", () => { process.env[RATE_LIMIT_ENV_NAME] = "true"; expect(loadConfig().authRateLimit).toBe(false); }); + +test("no local origins are allowed unless listed", () => { + delete process.env[ORIGINS_ENV_NAME]; + expect(loadConfig().allowedLocalOrigins).toEqual([]); + process.env[ORIGINS_ENV_NAME] = " , "; + expect(loadConfig().allowedLocalOrigins).toEqual([]); +}); + +test("listed local origins are normalized and deduplicated", () => { + process.env[ORIGINS_ENV_NAME] = + "http://127.0.0.1:4790, http://127.0.0.1:4790/ ,http://[::1]:8080"; + expect(loadConfig().allowedLocalOrigins).toEqual(["http://127.0.0.1:4790", "http://[::1]:8080"]); +}); + +test.each([ + "http://localhost:4790", + "http://127.0.0.1:4790/api", + "169.254.169.254", + "http://169.254.169.254", +])("a local origin that is not a bare IP-literal origin refuses to boot: %s", (raw) => { + process.env[ORIGINS_ENV_NAME] = raw; + expect(() => loadConfig()).toThrow(/EXECUTOR_ALLOWED_LOCAL_ORIGINS/); +}); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 749839be3e..6add20411d 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -64,6 +64,12 @@ export interface HostConfigShape { * production hosts leave it off. Drives `makeHostedHttpClientLayer`. */ readonly allowLocalNetwork: boolean; + /** + * Exact local/private origins the hosted HTTP client may dial while + * `allowLocalNetwork` is off (e.g. one loopback API). See + * `HostedHttpClientOptions.allowedLocalOrigins`. + */ + readonly allowedLocalOrigins?: ReadonlyArray; /** Require TLS for public outbound requests from both execution and admin views. */ readonly requireTls?: boolean; /** @@ -316,6 +322,7 @@ export const makeScopedExecutor = < const plugins = yield* Effect.sync(() => pluginsFactory(options?.plugins)); const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, + allowedLocalOrigins: config.allowedLocalOrigins, requireTls: config.requireTls, }; const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions); @@ -418,6 +425,7 @@ export const makePlatformExecutor = ( ); const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, + allowedLocalOrigins: config.allowedLocalOrigins, requireTls: config.requireTls, }; diff --git a/packages/core/sdk/src/host-internal.ts b/packages/core/sdk/src/host-internal.ts index 51d9292252..ce752848f2 100644 --- a/packages/core/sdk/src/host-internal.ts +++ b/packages/core/sdk/src/host-internal.ts @@ -33,6 +33,7 @@ export { HostedOutboundRequestBlocked, makeHostedFetch, makeHostedHttpClientLayer, + normalizeAllowedLocalOrigin, spanRedactedHeaderNames, type HostedHttpClientOptions, } from "./hosted-http-client"; diff --git a/packages/core/sdk/src/hosted-http-client.test.ts b/packages/core/sdk/src/hosted-http-client.test.ts index 7b377b2f6c..353c6f07f5 100644 --- a/packages/core/sdk/src/hosted-http-client.test.ts +++ b/packages/core/sdk/src/hosted-http-client.test.ts @@ -7,6 +7,7 @@ import { type HostedHostnameResolver, makeHostedFetch, makeHostedHttpClientLayer, + normalizeAllowedLocalOrigin, validateHostedOutboundUrl, } from "./hosted-http-client"; @@ -397,3 +398,83 @@ describe("hosted TLS policy", () => { expect(calls).toBe(1); }); }); + +describe("allowedLocalOrigins", () => { + const allowed = { allowedLocalOrigins: ["http://127.0.0.1:4790"] }; + + it.effect("allows exactly the listed origin, on any path", () => + Effect.gen(function* () { + yield* validateHostedOutboundUrl("http://127.0.0.1:4790/openapi.json", allowed); + yield* validateHostedOutboundUrl("http://127.0.0.1:4790/transactions?q=x", allowed); + }), + ); + + it.effect("keeps every other local or private target blocked", () => + Effect.gen(function* () { + for (const url of [ + "http://127.0.0.1:4791/", + "http://127.0.0.1/", + "http://127.0.0.2:4790/", + "https://127.0.0.1:4790/", + "http://localhost:4790/", + "http://[::1]:4790/", + "http://[::ffff:127.0.0.1]:4790/", + "http://10.250.0.10/", + "http://192.168.1.1/", + "http://100.100.100.100/", + "http://169.254.169.254/latest/meta-data/", + ]) { + const error = yield* validateHostedOutboundUrl(url, allowed).pipe(Effect.flip); + expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true); + } + }), + ); + + it.effect("never lets a hostname through, even one resolving to the listed address", () => + Effect.gen(function* () { + const error = yield* validateHostedOutboundUrl("http://ledger.example:4790/", { + ...allowed, + resolveHostname: async () => [{ address: "127.0.0.1", family: 4 }], + }).pipe(Effect.flip); + expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true); + }), + ); + + it("re-validates redirects away from the listed origin", async () => { + const seen: string[] = []; + const guarded = makeHostedFetch({ + ...allowed, + resolveHostname: publicResolver, + fetch: (async (input) => { + const url = input instanceof Request ? input.url : String(input); + seen.push(url); + return new Response(null, { + status: 302, + headers: { location: "http://127.0.0.1:4791/other" }, + }); + }) as typeof globalThis.fetch, + }); + await expect(guarded("http://127.0.0.1:4790/start")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(seen).toEqual(["http://127.0.0.1:4790/start"]); + }); + + it("normalizes entries and rejects anything but a bare IP-literal origin", () => { + expect(normalizeAllowedLocalOrigin("http://127.0.0.1:4790")).toBe("http://127.0.0.1:4790"); + expect(normalizeAllowedLocalOrigin(" http://127.0.0.1:4790/ ")).toBe("http://127.0.0.1:4790"); + expect(normalizeAllowedLocalOrigin("http://[::1]:4790")).toBe("http://[::1]:4790"); + for (const bad of [ + "127.0.0.1:4790", + "http://localhost:4790", + "http://ledger.example:4790", + "http://127.0.0.1:4790/api", + "http://user:pw@127.0.0.1:4790", + "http://169.254.169.254", + "ftp://127.0.0.1:4790", + "not a url", + ]) { + expect(normalizeAllowedLocalOrigin(bad)).toBeNull(); + } + }); +}); diff --git a/packages/core/sdk/src/hosted-http-client.ts b/packages/core/sdk/src/hosted-http-client.ts index a4009358ed..ebe8e4e81b 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -20,6 +20,13 @@ export type HostedHostnameResolver = ( export interface HostedHttpClientOptions { readonly allowLocalNetwork?: boolean; + /** + * Exact origins (`scheme://ip:port`) that may be dialled even though they + * are local or private, while `allowLocalNetwork` stays off. Hosts must be + * IP literals, so DNS never decides; metadata addresses are never allowed. + * Every redirect hop is still validated on its own. + */ + readonly allowedLocalOrigins?: ReadonlyArray; /** Require HTTPS, except private addresses explicitly allowed for local development. */ readonly requireTls?: boolean; readonly maxRedirects?: number; @@ -127,6 +134,34 @@ const isAddressLiteral = (hostname: string): boolean => { return parseIpv4(normalized) !== null || /^[0-9a-f:.]+$/i.test(normalized); }; +/** + * Normalizes one `allowedLocalOrigins` entry to its URL origin, or returns + * null when it is not a bare `http(s)://[:port]` origin. + */ +export const normalizeAllowedLocalOrigin = (value: string): string | null => { + let url: URL; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing of operator config + try { + url = new URL(value.trim()); + } catch { + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password || url.search || url.hash) return null; + if (url.pathname !== "/" && url.pathname !== "") return null; + if (!isAddressLiteral(url.hostname)) return null; + if (isBlockedMetadataHostname(url.hostname)) return null; + return url.origin; +}; + +const isAllowedLocalOrigin = (url: URL, options: HostedHttpClientOptions): boolean => { + if (!options.allowedLocalOrigins || options.allowedLocalOrigins.length === 0) return false; + if (!isAddressLiteral(url.hostname)) return false; + return options.allowedLocalOrigins.some( + (entry) => normalizeAllowedLocalOrigin(entry) === url.origin, + ); +}; + const resolveHostnameWithNodeDns: HostedHostnameResolver = async (hostname) => { const { lookup } = await import("node:dns/promises"); const addresses = await lookup(hostname, { all: true, verbatim: true }); @@ -157,6 +192,18 @@ export const validateHostedOutboundUrl = ( }); } + if (isBlockedMetadataHostname(url.hostname)) { + return yield* new HostedOutboundRequestBlocked({ + url: value, + reason: "Metadata service addresses are not allowed", + }); + } + + // An operator-listed exact origin (IP literal, so no DNS) is allowed + // regardless of allowLocalNetwork. Checked per hop, so a redirect away + // from it is validated like any other URL. + if (isAllowedLocalOrigin(url, options)) return; + if ( options.requireTls && url.protocol !== "https:" && @@ -168,13 +215,6 @@ export const validateHostedOutboundUrl = ( }); } - if (isBlockedMetadataHostname(url.hostname)) { - return yield* new HostedOutboundRequestBlocked({ - url: value, - reason: "Metadata service addresses are not allowed", - }); - } - if (!options.allowLocalNetwork && isLocalOrPrivateHostname(url.hostname)) { return yield* new HostedOutboundRequestBlocked({ url: value,