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
5 changes: 5 additions & 0 deletions .changeset/allowed-local-origins.md
Original file line number Diff line number Diff line change
@@ -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)://<ip>:<port>` 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.
1 change: 1 addition & 0 deletions apps/docs/hosted/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)://<ip>:<port>` 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
Expand Down
26 changes: 26 additions & 0 deletions apps/host-selfhost/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)://<ip literal>:<port>`).
*/
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)://<ip literal>[:port] origin`,
);
}
return origin;
});
return [...new Set(origins)];
};

const resolveTrustedOrigins = (webBaseUrl: string): readonly string[] => {
const additional = (process.env.EXECUTOR_TRUSTED_ORIGINS ?? "")
.split(",")
Expand Down
1 change: 1 addition & 0 deletions apps/host-selfhost/src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig
const config = loadConfig();
return {
allowLocalNetwork: config.allowLocalNetwork,
allowedLocalOrigins: config.allowedLocalOrigins,
webBaseUrl: config.webBaseUrl,
oauthCallbackPath: "/api/oauth/callback",
toolsSyncTtlMs: config.toolsSyncTtlMs,
Expand Down
30 changes: 30 additions & 0 deletions apps/host-selfhost/src/executor-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 => {
Expand Down Expand Up @@ -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/);
});
8 changes: 8 additions & 0 deletions packages/core/api/src/server/scoped-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** Require TLS for public outbound requests from both execution and admin views. */
readonly requireTls?: boolean;
/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -418,6 +425,7 @@ export const makePlatformExecutor = (
);
const hostedHttpOptions = {
allowLocalNetwork: config.allowLocalNetwork,
allowedLocalOrigins: config.allowedLocalOrigins,
requireTls: config.requireTls,
};

Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/host-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export {
HostedOutboundRequestBlocked,
makeHostedFetch,
makeHostedHttpClientLayer,
normalizeAllowedLocalOrigin,
spanRedactedHeaderNames,
type HostedHttpClientOptions,
} from "./hosted-http-client";
Expand Down
81 changes: 81 additions & 0 deletions packages/core/sdk/src/hosted-http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type HostedHostnameResolver,
makeHostedFetch,
makeHostedHttpClientLayer,
normalizeAllowedLocalOrigin,
validateHostedOutboundUrl,
} from "./hosted-http-client";

Expand Down Expand Up @@ -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();
}
});
});
54 changes: 47 additions & 7 deletions packages/core/sdk/src/hosted-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** Require HTTPS, except private addresses explicitly allowed for local development. */
readonly requireTls?: boolean;
readonly maxRedirects?: number;
Expand Down Expand Up @@ -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)://<ip literal>[: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 });
Expand Down Expand Up @@ -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:" &&
Expand All @@ -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,
Expand Down
Loading