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
2 changes: 2 additions & 0 deletions apps/host-cloudflare/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ dist/
.dev.vars
.wrangler/
wrangler.preview.jsonc
.wrangler.deploy.json
.wrangler.local.json
38 changes: 38 additions & 0 deletions apps/host-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,39 @@ org. Members and credentials are managed in Cloudflare Access, not in-app.
everything else is the SPA. Every API/MCP route is gated by the Access JWT (401
without). The SPA's auth context reads `/api/account/me`.

### Internal tools over service bindings

The surfaces above are inbound. Outbound, a private MCP server in another Worker
is reached over a service binding, never the internet. `INTERNAL_MCP_HOSTS` is a
comma-separated list of `host=BINDING` pairs (host ends in `.internal`), and each
BINDING is a service binding on this Worker:

```jsonc
"services": [{ "binding": "TOOLS", "service": "my-tools", "entrypoint": "ToolsMcp" }],
"vars": { "INTERNAL_MCP_HOSTS": "tools.internal=TOOLS" }
```

An MCP integration with endpoint `https://tools.internal/mcp/websearch` is then
served by `env.TOOLS.fetch(request)`, for the MCP transport and for the plain
fetch (OAuth discovery, probes, `/.well-known/*`; the bound Worker should answer
404 for paths it does not serve). The match is on the exact hostname, outside the
SSRF guard. Every other host takes the guarded path unchanged, any other
`*.internal` host is refused, and an external server redirecting to an internal
host stays blocked. Requests to a binding go out with `redirect: "manual"`, so a
redirect answered by the bound Worker reaches the caller as is and is never
followed. A host naming a missing binding fails config load: the Worker answers
503 with a message naming the host and the binding. Both the Worker and
`McpSessionDO` read the same config, so both paths route.

**Trust boundary.** The boundary is the Cloudflare account, not Executor: any
Worker in the same account could bind the target Worker, so the bound Worker
must be private (no public route, `workers_dev` and preview URLs off) and
Executor is its only intended caller. Hosts match exactly (`tools.internal`, not
`x.tools.internal`), and a redirect from an external server into `*.internal` is
refused. Because an integration pointing at an internal host reaches code that
skips the SSRF guard, only admins should create such integrations, never
end users.

## Deploy

```bash
Expand Down Expand Up @@ -75,6 +108,11 @@ cd apps/host-cloudflare
bun run deploy # vite build -> assert-shell-asset -> wrangler deploy
```

If the deployment declares internal service bindings, deploy through the
monorepo that owns them (`pnpm executor:deploy`) instead of `bun run deploy`
here. A deploy from this directory does not carry those bindings, and internal
integrations then fail until it is redeployed the right way.

Requires `bunx wrangler login` to the Skyward account. `keep_vars` preserves
the Access variables set above, so there is nothing else to pass. Do not
re-run `deploy:setup` for a routine deploy; that script is for first-time
Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/src/account/account-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const config: CloudflareConfig = {
organizationSlug: "default",
secretKey: "x".repeat(32),
allowLocalNetwork: false,
internalHosts: {},
webBaseUrl: "https://localhost",
enableDevAuth: false,
};
Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/src/auth/cloudflare-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const config: CloudflareConfig = {
organizationSlug: "default",
secretKey: "x".repeat(32),
allowLocalNetwork: false,
internalHosts: {},
webBaseUrl: "https://localhost",
enableDevAuth: false,
};
Expand Down
37 changes: 37 additions & 0 deletions apps/host-cloudflare/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,40 @@ describe("jevGateway", () => {
expect(config.jevGateway?.authToken).toBeUndefined();
});
});

describe("INTERNAL_MCP_HOSTS", () => {
const TOOLS = { fetch: async () => new Response("ok") };
const devEnv = (overrides: Record<string, unknown>): ConfigEnv =>
Object.assign(makeEnv({ ENABLE_DEV_AUTH: "true" }), overrides);

it("resolves a host to the service binding it names", () => {
const config = loadConfig(devEnv({ INTERNAL_MCP_HOSTS: "Tools.Internal=TOOLS", TOOLS }));

expect(Object.keys(config.internalHosts)).toEqual(["tools.internal"]);
expect(config.internalHosts["tools.internal"]).toBe(TOOLS);
});

it("accepts a trailing dot on the host and stores it normalised", () => {
const config = loadConfig(devEnv({ INTERNAL_MCP_HOSTS: "Tools.Internal.=TOOLS", TOOLS }));

expect(Object.keys(config.internalHosts)).toEqual(["tools.internal"]);
});

it("has no internal hosts when the variable is unset", () => {
expect(loadConfig(devEnv({ TOOLS })).internalHosts).toEqual({});
});

it("fails loudly when a host names a binding that is absent", () => {
expect(() => loadConfig(devEnv({ INTERNAL_MCP_HOSTS: "tools.internal=TOOLS" }))).toThrowError(
"INTERNAL_MCP_HOSTS maps tools.internal to TOOLS, but no service binding named TOOLS is configured",
);
});

it("refuses a host that does not end in .internal", () => {
expect(() =>
loadConfig(devEnv({ INTERNAL_MCP_HOSTS: "api.example.com=TOOLS", TOOLS })),
).toThrowError(
'INTERNAL_MCP_HOSTS entry "api.example.com=TOOLS" must look like "tools.internal=TOOLS" (a host ending in .internal, then a service binding name)',
);
});
});
49 changes: 49 additions & 0 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/w

import type { JevGatewayConfig } from "@executor-js/execution";
import { isValidOrgSlug } from "@executor-js/api";
import {
isInternalHostname,
normalizeHostname,
type HostedInternalFetcher,
type HostedInternalHosts,
} from "@executor-js/sdk/host-internal";
import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin";

let warnedNoCloudflareOrigin = false;
Expand Down Expand Up @@ -61,6 +67,12 @@ export interface CloudflareEnv {
/** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */
readonly EXECUTOR_SECRET_KEY?: string;
readonly ALLOW_LOCAL_NETWORK?: string;
/**
* Private MCP hosts served by service bindings: comma-separated `host=BINDING`
* pairs, e.g. `tools.internal=TOOLS`. Each BINDING names a service binding on
* this Worker (wrangler `services`).
*/
readonly INTERNAL_MCP_HOSTS?: string;
/**
* Cloudflare AI Gateway, for the Jev classifier (`custom-typesafe`). Every
* model call goes through a gateway rather than a provider directly, so the
Expand Down Expand Up @@ -102,6 +114,8 @@ export interface CloudflareConfig {
readonly organizationSlug: string;
readonly secretKey: string;
readonly allowLocalNetwork: boolean;
/** Internal MCP hosts mapped to their service bindings (see `INTERNAL_MCP_HOSTS`). */
readonly internalHosts: HostedInternalHosts;
/** Explicit web base URL (`VITE_PUBLIC_SITE_URL`). Unset on a Worker with no
* static URL — the per-request origin is used instead (see RequestWebOrigin). */
readonly webBaseUrl?: string;
Expand Down Expand Up @@ -187,6 +201,40 @@ const resolveOrgSlug = (value: string | undefined): string => {
return value;
};

const isInternalFetcher = (value: unknown): value is HostedInternalFetcher =>
typeof value === "object" &&
value !== null &&
"fetch" in value &&
typeof value.fetch === "function";

// A host naming an absent binding must stop the Worker (503 at the door) rather
// than leave an integration that silently cannot be reached.
const resolveInternalHosts = (env: CloudflareConfigEnv): HostedInternalHosts => {
const hosts: Record<string, HostedInternalFetcher> = {};
for (const pair of (env.INTERNAL_MCP_HOSTS ?? "").split(",")) {
const entry = pair.trim();
if (entry.length === 0) continue;
const [rawHost, rawBinding, ...rest] = entry.split("=").map((part) => part.trim());
const host = normalizeHostname(rawHost ?? "");
const binding = rawBinding ?? "";
if (rest.length > 0 || !isInternalHostname(host) || binding.length === 0) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a malformed internal host mapping must fail the boot, not route silently
throw new Error(
`INTERNAL_MCP_HOSTS entry ${JSON.stringify(entry)} must look like "tools.internal=TOOLS" (a host ending in .internal, then a service binding name)`,
);
}
const fetcher: unknown = Reflect.get(env, binding);
if (!isInternalFetcher(fetcher)) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: an internal host whose service binding is absent must fail loudly instead of leaving an unreachable integration
throw new Error(
`INTERNAL_MCP_HOSTS maps ${host} to ${binding}, but no service binding named ${binding} is configured`,
);
}
hosts[host] = fetcher;
}
return hosts;
};

export interface LoadConfigOptions {
/** Build the trusted config for the service-binding entrypoint (see
* {@link CloudflareConfig.trustedInternal}). */
Expand Down Expand Up @@ -234,6 +282,7 @@ export const loadConfig = (
organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG),
secretKey,
allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
internalHosts: resolveInternalHosts(env),
// Pinned origin via the shared resolver. A Worker receives no PaaS platform
// vars (env: {} — there is nothing to detect), so only the explicit
// VITE_PUBLIC_SITE_URL applies; when it's unset we leave webBaseUrl undefined
Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const makeCloudflarePluginsProvider = (
export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>
Layer.succeed(HostConfig)({
allowLocalNetwork: config.allowLocalNetwork,
internalHosts: config.internalHosts,
webBaseUrl: config.webBaseUrl,
oauthCallbackPath: "/api/oauth/callback",
// Each MCP session's executor lives in a Durable Object with a small memory
Expand Down
6 changes: 6 additions & 0 deletions apps/host-cloudflare/src/worker.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsof
// ---------------------------------------------------------------------------

const dir = fileURLToPath(new URL(".", import.meta.url));
// wrangler loads `.dev.vars` next to the config only when `envFiles` is empty; a non-empty list
// that names no existing file makes the worker see exactly the `vars` each test passes, never a
// developer's local `.dev.vars` (which enables dev auth).
const HERMETIC_ENV_FILES = [".e2e-no-env-file"];
const runId = randomUUID().slice(0, 8);

const ensureStaticAssets = () => {
Expand Down Expand Up @@ -99,6 +103,7 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
ip: "127.0.0.1",
local: true,
persist: false,
envFiles: HERMETIC_ENV_FILES,
experimental: { disableExperimentalWarning: true },
vars: {
EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
Expand Down Expand Up @@ -627,6 +632,7 @@ describe("cloudflare host configuration errors", () => {
ip: "127.0.0.1",
local: true,
persist: false,
envFiles: HERMETIC_ENV_FILES,
experimental: { disableExperimentalWarning: true },
vars: {
EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/api/src/server/scoped-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
makeHostedFetch,
makeHostedHttpClientLayer,
touchSubject,
type HostedInternalHosts,
} from "@executor-js/sdk/host-internal";

import { DbProvider } from "./executor-fuma-db";
Expand All @@ -65,6 +66,13 @@ export interface HostConfigShape {
* production hosts leave it off. Drives `makeHostedHttpClientLayer`.
*/
readonly allowLocalNetwork: boolean;
/**
* Private hosts served by a service binding, keyed by exact lowercase hostname
* (`tools.internal`). Requests to them skip the network and the SSRF guard; any
* other `*.internal` host is refused. Applies to the MCP transport and to the
* plain fetch (OAuth discovery, probes).
*/
readonly internalHosts?: HostedInternalHosts;
/**
* AI Gateway for the Jev classifier. Present only on a host that configured
* one; absent leaves tool search purely lexical, which is the correct default
Expand Down Expand Up @@ -330,6 +338,7 @@ export const makeScopedExecutor = <
const hostedHttpOptions = {
allowLocalNetwork: config.allowLocalNetwork,
requireTls: config.requireTls,
internalHosts: config.internalHosts,
};
const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions);
const hostedFetch = makeHostedFetch(hostedHttpOptions);
Expand Down Expand Up @@ -435,6 +444,7 @@ export const makePlatformExecutor = (
const hostedHttpOptions = {
allowLocalNetwork: config.allowLocalNetwork,
requireTls: config.requireTls,
internalHosts: config.internalHosts,
};

return yield* createExecutor({
Expand Down
4 changes: 4 additions & 0 deletions packages/core/sdk/src/host-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@

export {
HostedOutboundRequestBlocked,
isInternalHostname,
makeHostedFetch,
makeHostedHttpClientLayer,
normalizeHostname,
spanRedactedHeaderNames,
type HostedHttpClientOptions,
type HostedInternalFetcher,
type HostedInternalHosts,
} from "./hosted-http-client";

export {
Expand Down
Loading
Loading