diff --git a/apps/host-cloudflare/.gitignore b/apps/host-cloudflare/.gitignore index 726b7f10f1..e82237e2a9 100644 --- a/apps/host-cloudflare/.gitignore +++ b/apps/host-cloudflare/.gitignore @@ -2,3 +2,5 @@ dist/ .dev.vars .wrangler/ wrangler.preview.jsonc +.wrangler.deploy.json +.wrangler.local.json diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 8bc2044e1f..1ac16f32d6 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -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 @@ -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 diff --git a/apps/host-cloudflare/src/account/account-provider.test.ts b/apps/host-cloudflare/src/account/account-provider.test.ts index 89d579536e..973344127f 100644 --- a/apps/host-cloudflare/src/account/account-provider.test.ts +++ b/apps/host-cloudflare/src/account/account-provider.test.ts @@ -15,6 +15,7 @@ const config: CloudflareConfig = { organizationSlug: "default", secretKey: "x".repeat(32), allowLocalNetwork: false, + internalHosts: {}, webBaseUrl: "https://localhost", enableDevAuth: false, }; diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index c9dbaad5b4..c1d9ad99f0 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -23,6 +23,7 @@ const config: CloudflareConfig = { organizationSlug: "default", secretKey: "x".repeat(32), allowLocalNetwork: false, + internalHosts: {}, webBaseUrl: "https://localhost", enableDevAuth: false, }; diff --git a/apps/host-cloudflare/src/config.test.ts b/apps/host-cloudflare/src/config.test.ts index 82b81094e9..ddf00e15f7 100644 --- a/apps/host-cloudflare/src/config.test.ts +++ b/apps/host-cloudflare/src/config.test.ts @@ -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): 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)', + ); + }); +}); diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index dc554ed741..1e07874d0f 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -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; @@ -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 @@ -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; @@ -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 = {}; + 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}). */ @@ -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 diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index 6e2174b7d8..44de35104b 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -54,6 +54,7 @@ export const makeCloudflarePluginsProvider = ( export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer => 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 diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 30798ca2f2..1cbcd306f1 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -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 = () => { @@ -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", @@ -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", diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 28dfc4cda7..950e3b1e5c 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -49,6 +49,7 @@ import { makeHostedFetch, makeHostedHttpClientLayer, touchSubject, + type HostedInternalHosts, } from "@executor-js/sdk/host-internal"; import { DbProvider } from "./executor-fuma-db"; @@ -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 @@ -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); @@ -435,6 +444,7 @@ export const makePlatformExecutor = ( const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, requireTls: config.requireTls, + internalHosts: config.internalHosts, }; return yield* createExecutor({ diff --git a/packages/core/sdk/src/host-internal.ts b/packages/core/sdk/src/host-internal.ts index 51d9292252..824b13cc14 100644 --- a/packages/core/sdk/src/host-internal.ts +++ b/packages/core/sdk/src/host-internal.ts @@ -31,10 +31,14 @@ export { HostedOutboundRequestBlocked, + isInternalHostname, makeHostedFetch, makeHostedHttpClientLayer, + normalizeHostname, spanRedactedHeaderNames, type HostedHttpClientOptions, + type HostedInternalFetcher, + type HostedInternalHosts, } from "./hosted-http-client"; export { diff --git a/packages/core/sdk/src/hosted-http-client.test.ts b/packages/core/sdk/src/hosted-http-client.test.ts index 7b377b2f6c..e689c5de6e 100644 --- a/packages/core/sdk/src/hosted-http-client.test.ts +++ b/packages/core/sdk/src/hosted-http-client.test.ts @@ -5,8 +5,10 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { type HostedHostnameResolver, + isInternalHostname, makeHostedFetch, makeHostedHttpClientLayer, + normalizeHostname, validateHostedOutboundUrl, } from "./hosted-http-client"; @@ -397,3 +399,209 @@ describe("hosted TLS policy", () => { expect(calls).toBe(1); }); }); + +describe("internal hostnames", () => { + it("normalises case and one trailing dot", () => { + expect(normalizeHostname("Tools.Internal.")).toBe("tools.internal"); + expect(normalizeHostname("tools.internal")).toBe("tools.internal"); + }); + + it("recognises *.internal hosts only", () => { + expect(isInternalHostname("TOOLS.internal.")).toBe(true); + expect(isInternalHostname("a.b.internal")).toBe(true); + expect(isInternalHostname("internal.example.com")).toBe(false); + expect(isInternalHostname("notinternal")).toBe(false); + }); +}); + +describe("internal host routing", () => { + const recordingFetcher = () => { + const seen: Array<{ url: string; method: string; header: string | null; body: string }> = []; + return { + seen, + fetch: async (request: Request) => { + seen.push({ + url: request.url, + method: request.method, + header: request.headers.get("x-probe"), + body: await request.text(), + }); + return new Response("from-binding", { status: 200 }); + }, + }; + }; + + const externalStub = () => { + const urls: string[] = []; + const fetch = (async (input) => { + const url = input instanceof Request ? input.url : String(input); + urls.push(url); + if (url === "https://evil.example/start") { + return new Response(null, { + status: 302, + headers: { location: "https://tools.internal/mcp/websearch" }, + }); + } + return new Response("from-network", { status: 200 }); + }) as typeof globalThis.fetch; + return { urls, fetch }; + }; + + it.effect("sends an internal host through the HTTP client to its binding", () => + Effect.gen(function* () { + const binding = recordingFetcher(); + const external = externalStub(); + const response = yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + return yield* client.execute( + HttpClientRequest.post("https://tools.internal/mcp/websearch").pipe( + HttpClientRequest.setHeader("x-probe", "abc"), + HttpClientRequest.bodyText('{"jsonrpc":"2.0"}', "application/json"), + ), + ); + }).pipe( + Effect.provide( + makeHostedHttpClientLayer({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }), + ), + ); + + expect(yield* response.text).toBe("from-binding"); + expect(binding.seen).toEqual([ + { + url: "https://tools.internal/mcp/websearch", + method: "POST", + header: "abc", + body: '{"jsonrpc":"2.0"}', + }, + ]); + expect(external.urls).toEqual([]); + }), + ); + + it("sends an internal host through the plain fetch to its binding", async () => { + const binding = recordingFetcher(); + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + const response = await hostedFetch( + "https://tools.internal/.well-known/oauth-authorization-server", + ); + + expect(await response.text()).toBe("from-binding"); + expect(binding.seen.map((call) => call.url)).toEqual([ + "https://tools.internal/.well-known/oauth-authorization-server", + ]); + expect(external.urls).toEqual([]); + }); + + it("hands the binding a manual-redirect request and returns its redirect untouched", async () => { + const seen: string[] = []; + const binding = { + fetch: async (request: Request) => { + seen.push(request.redirect); + return new Response(null, { + status: 302, + headers: { location: "https://elsewhere.example/loot" }, + }); + }, + }; + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + const response = await hostedFetch("https://tools.internal/mcp/websearch", { + redirect: "follow", + }); + + expect(seen).toEqual(["manual"]); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("https://elsewhere.example/loot"); + expect(external.urls).toEqual([]); + }); + + it("sends an external host through the guarded fetch", async () => { + const binding = recordingFetcher(); + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + const response = await hostedFetch("https://api.example/openapi.json"); + + expect(await response.text()).toBe("from-network"); + expect(external.urls).toEqual(["https://api.example/openapi.json"]); + expect(binding.seen).toEqual([]); + }); + + it("still blocks a private address for an external host", async () => { + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + internalHosts: { "tools.internal": recordingFetcher() }, + }); + + await expect(hostedFetch("http://127.0.0.1:3000/mcp")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(external.urls).toEqual([]); + }); + + it("rejects an external redirect into an internal host", async () => { + const binding = recordingFetcher(); + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + await expect(hostedFetch("https://evil.example/start")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(external.urls).toEqual(["https://evil.example/start"]); + expect(binding.seen).toEqual([]); + }); + + it("rejects an internal host with no binding, naming the host", async () => { + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": recordingFetcher() }, + }); + + await expect(hostedFetch("https://other.internal/mcp")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + reason: "No internal binding is configured for host other.internal", + }); + expect(external.urls).toEqual([]); + }); + + it("matches the host exactly, not by suffix", async () => { + const binding = recordingFetcher(); + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + await expect(hostedFetch("https://x.tools.internal/mcp")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(binding.seen).toEqual([]); + }); +}); diff --git a/packages/core/sdk/src/hosted-http-client.ts b/packages/core/sdk/src/hosted-http-client.ts index a4009358ed..d48460ceed 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -18,7 +18,22 @@ export type HostedHostnameResolver = ( hostname: string, ) => Promise>; +/** A Workers service binding (or anything shaped like one). */ +export interface HostedInternalFetcher { + readonly fetch: (request: Request) => Promise; +} + +/** Exact lowercase hostname to the fetcher that serves it, e.g. `tools.internal`. */ +export type HostedInternalHosts = Readonly>; + export interface HostedHttpClientOptions { + /** + * Private hosts served by a service binding instead of the network. A request + * whose hostname matches exactly goes to the fetcher, outside the SSRF guard; + * any other `*.internal` host is rejected, and the guard refuses redirects into + * one. Empty or absent leaves routing off. + */ + readonly internalHosts?: HostedInternalHosts; readonly allowLocalNetwork?: boolean; /** Require HTTPS, except private addresses explicitly allowed for local development. */ readonly requireTls?: boolean; @@ -219,6 +234,19 @@ const stripCredentialHeaders = (init: RequestInit | undefined): RequestInit => { return { ...init, headers }; }; +const INTERNAL_SUFFIX = ".internal"; + +const hasInternalHosts = (options: HostedHttpClientOptions): boolean => + Object.keys(options.internalHosts ?? {}).length > 0; + +/** Lowercase, without a trailing dot: the form `internalHosts` keys are matched in. */ +export const normalizeHostname = (hostname: string): string => + hostname.toLowerCase().replace(/\.$/, ""); + +/** True for any `*.internal` host, whether or not a binding is configured for it. */ +export const isInternalHostname = (hostname: string): boolean => + normalizeHostname(hostname).endsWith(INTERNAL_SUFFIX); + const guardFetch = ( underlying: typeof globalThis.fetch, options: HostedHttpClientOptions, @@ -233,6 +261,16 @@ const guardFetch = ( let currentInit = init; for (let redirects = 0; redirects <= maxRedirects; redirects++) { const url = current instanceof Request ? current.url : String(current); + if (hasInternalHosts(options) && isInternalHostname(new URL(url).hostname)) { + await Effect.runPromise( + Effect.fail( + new HostedOutboundRequestBlocked({ + url, + reason: "Internal hosts are only reachable directly, not through a redirect", + }), + ), + ); + } await Effect.runPromise(validateHostedOutboundUrl(url, guardOptions)); const response = await underlying(current, { ...currentInit, @@ -259,9 +297,41 @@ const guardFetch = ( return await underlying(current, { ...currentInit, redirect: "manual" }); }) as typeof globalThis.fetch; +// The router sits OUTSIDE the guard: a configured internal host goes straight to +// its binding, everything else takes the guarded path unchanged. +const routeInternalHosts = ( + guarded: typeof globalThis.fetch, + options: HostedHttpClientOptions, +): typeof globalThis.fetch => { + const hosts = options.internalHosts ?? {}; + if (!hasInternalHosts(options)) return guarded; + return (async (input, init) => { + // `manual`: a 3xx from the bound Worker comes back to the caller as is. The + // runtime would otherwise follow its `Location` outside the SSRF guard. + const request = new Request(input, { ...init, redirect: "manual" }); + const hostname = normalizeHostname(new URL(request.url).hostname); + const target = Object.hasOwn(hosts, hostname) ? hosts[hostname] : undefined; + if (target) return await target.fetch(request); + if (isInternalHostname(hostname)) { + return await Effect.runPromise( + Effect.fail( + new HostedOutboundRequestBlocked({ + url: request.url, + reason: `No internal binding is configured for host ${hostname}`, + }), + ), + ); + } + return await guarded(request); + }) as typeof globalThis.fetch; +}; + export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch => - // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch - guardFetch(options.fetch ?? globalThis.fetch, options); + routeInternalHosts( + // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch + guardFetch(options.fetch ?? globalThis.fetch, options), + options, + ); // --------------------------------------------------------------------------- // Span header redaction. @@ -336,11 +406,13 @@ export const makeHostedHttpClientLayer = ( Layer.provide(FetchHttpClient.layer), Layer.provide( options.fetch - ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options)) + ? Layer.succeed(FetchHttpClient.Fetch)( + routeInternalHosts(guardFetch(options.fetch, options), options), + ) : Layer.effect( FetchHttpClient.Fetch, Effect.map(Effect.service(FetchHttpClient.Fetch), (underlying) => - guardFetch(underlying, options), + routeInternalHosts(guardFetch(underlying, options), options), ), ), ), diff --git a/packages/plugins/mcp/src/sdk/internal-host.test.ts b/packages/plugins/mcp/src/sdk/internal-host.test.ts new file mode 100644 index 0000000000..6ad1fc4c3c --- /dev/null +++ b/packages/plugins/mcp/src/sdk/internal-host.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; + +import { createMcpConnector } from "./connection"; +import { discoverTools } from "./discover"; +import { makeEchoMcpServer } from "../testing"; + +// A fake service binding that is a real (stateless) MCP server, so the full +// initialize and tools/list handshake crosses the internal route. +const mcpBinding = () => { + const seen: string[] = []; + return { + seen, + fetch: async (request: Request) => { + seen.push(`${request.method} ${new URL(request.url).pathname}`); + const server = makeEchoMcpServer({ name: "websearch", toolName: "search" }); + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + }); + await server.connect(transport); + return transport.handleRequest(request); + }, + }; +}; + +describe("MCP over an internal service binding", () => { + it.live("discovers the catalog of an integration at an internal host", () => + Effect.gen(function* () { + const binding = mcpBinding(); + const manifest = yield* discoverTools( + createMcpConnector({ + transport: "remote", + endpoint: "https://tools.internal/mcp/websearch", + remoteTransport: "streamable-http", + httpClientLayer: makeHostedHttpClientLayer({ + internalHosts: { "tools.internal": binding }, + }), + }), + ); + + expect(manifest.server?.name).toBe("websearch"); + expect(manifest.tools.map((tool) => tool.toolName)).toEqual(["search"]); + expect(binding.seen.length).toBeGreaterThan(0); + expect(binding.seen.every((call) => call.endsWith(" /mcp/websearch"))).toBe(true); + }), + ); +});