From f5906e3424090c43a9791354e281226c5d5e1aaf Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Tue, 23 Jun 2026 09:34:52 -0700 Subject: [PATCH 1/5] Route OAuth token fetches through hosted guard --- .../core/api/src/server/scoped-executor.ts | 9 ++-- packages/core/sdk/src/executor.ts | 8 ++++ packages/core/sdk/src/host-internal.ts | 11 +++-- .../core/sdk/src/hosted-http-client.test.ts | 17 ++++++++ packages/core/sdk/src/hosted-http-client.ts | 4 ++ packages/core/sdk/src/oauth-helpers.test.ts | 43 +++++++++++++++++++ packages/core/sdk/src/oauth-helpers.ts | 28 ++++++++++-- packages/core/sdk/src/oauth-service.ts | 4 ++ 8 files changed, 114 insertions(+), 10 deletions(-) diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index de9b52ae9..cd48cf0c8 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -41,7 +41,7 @@ import { type Executor, type StorageFailure, } from "@executor-js/sdk"; -import { makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; +import { makeHostedFetch, makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; import { DbProvider } from "./executor-fuma-db"; @@ -241,9 +241,11 @@ export const makeScopedExecutor = < }); const plugins = pluginsFactory(); - const httpClientLayer = makeHostedHttpClientLayer({ + const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, - }); + }; + const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions); + const hostedFetch = makeHostedFetch(hostedHttpOptions); // The org id is the tenant (catalog partition); the account id is the acting // subject (drives `owner: "user"` rows). `organizationName` is no longer part @@ -255,6 +257,7 @@ export const makeScopedExecutor = < blobs, plugins, httpClientLayer, + fetch: hostedFetch, onElicitation: "accept-all", redirectUri, coreTools: { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index dcd85819e..9e0ad63aa 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -357,6 +357,11 @@ export interface ExecutorConfig; + /** + * Fetch API implementation for dependencies that cannot consume `httpClientLayer`. + * Prefer `httpClientLayer` for normal SDK and plugin HTTP. + */ + readonly fetch?: typeof globalThis.fetch; /** * The OAuth callback URL (`${webBaseUrl}/oauth/callback`) the host serves and * sends to providers. There is NO localhost default: omit it (or pass @@ -1455,6 +1460,7 @@ export const createExecutor = cause.error === "invalid_grant" @@ -3079,6 +3086,7 @@ export const createExecutor = { }), ); + it("applies the DNS guard to fetch callers", async () => { + let calls = 0; + const hostedFetch = makeHostedFetch({ + fetch: (async () => { + calls++; + return new Response("unexpected", { status: 200 }); + }) as typeof globalThis.fetch, + resolveHostname: async () => [{ address: "10.0.0.20", family: 4 }], + }); + + await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(calls).toBe(0); + }); + it.effect("checks redirected URLs before following them", () => Effect.gen(function* () { let calls = 0; diff --git a/packages/core/sdk/src/hosted-http-client.ts b/packages/core/sdk/src/hosted-http-client.ts index 029ec3367..37f1ea427 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -246,6 +246,10 @@ const guardFetch = ( return await underlying(current, { ...currentInit, redirect: "manual" }); }) 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); + export const makeHostedHttpClientLayer = ( options: HostedHttpClientOptions = {}, ): Layer.Layer => diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index e047e88d8..3ed7037f5 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -608,6 +608,49 @@ describe("exchangeAuthorizationCode", () => { }); describe("exchangeClientCredentials", () => { + it.effect("routes token grant requests through the injected fetch", () => + withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl }) => + Effect.gen(function* () { + const seen: Array<{ url: string; method: string | undefined }> = []; + const customFetch: typeof globalThis.fetch = (async (input, init) => { + seen.push({ + url: input instanceof Request ? input.url : String(input), + method: init?.method, + }); + // oxlint-disable-next-line executor/no-raw-fetch -- boundary: test fetch adapter delegates to the local token endpoint + return fetch(input, init); + }) as typeof globalThis.fetch; + + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + fetch: customFetch, + }); + yield* exchangeClientCredentials({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + fetch: customFetch, + }); + yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + refreshToken: "old", + fetch: customFetch, + }); + + expect(seen).toEqual([ + { url: tokenUrl, method: "POST" }, + { url: tokenUrl, method: "POST" }, + { url: tokenUrl, method: "POST" }, + ]); + }), + ), + ); + it.effect("rejects unsupported token URL schemes before exchange", () => Effect.gen(function* () { const exit = yield* Effect.exit( diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 9672f0bd2..c619704ff 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -417,10 +417,14 @@ const oauth4webapiRequestOptions = ( targetUrl: string, timeoutMs: number | undefined, endpointUrlPolicy: OAuthEndpointUrlPolicy = {}, + customFetch?: typeof globalThis.fetch, ): Record => { const options: Record = { signal: AbortSignal.timeout(timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS), }; + if (customFetch) { + (options as { [oauth.customFetch]?: typeof globalThis.fetch })[oauth.customFetch] = customFetch; + } if ( isLoopbackHttpUrl(targetUrl) || (URL.canParse(targetUrl) && @@ -510,6 +514,7 @@ export type ExchangeAuthorizationCodeInput = { readonly resource?: string; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; }; export const exchangeAuthorizationCode = ( @@ -545,7 +550,12 @@ export const exchangeAuthorizationCode = ( clientAuth, "authorization_code", params, - oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), + oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), ); return await processTokenEndpointResponse(as, client, response); }, @@ -568,6 +578,7 @@ export type ExchangeClientCredentialsInput = { readonly resource?: string; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; }; export const exchangeClientCredentials = ( @@ -593,7 +604,12 @@ export const exchangeClientCredentials = ( client, clientAuth, params, - oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), + oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), ); const result = await oauth.processClientCredentialsResponse(as, client, response); return tokenResponseFrom(result); @@ -621,6 +637,7 @@ export type RefreshAccessTokenInput = { readonly resource?: string; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; }; export const refreshAccessToken = ( @@ -652,7 +669,12 @@ export const refreshAccessToken = ( clientAuth, input.refreshToken, { - ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), + ...oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), additionalParameters, }, ); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index ea850c18b..008719b75 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -132,6 +132,7 @@ export interface OAuthServiceDeps { template: AuthTemplateSlug, ) => Effect.Effect; readonly httpClientLayer?: Layer.Layer; + readonly fetch?: typeof globalThis.fetch; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; /** * The OAuth callback URL (`${webBaseUrl}${mountPrefix}/oauth/callback`) the host @@ -341,6 +342,7 @@ const validateClientEndpoints = ( export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; + const fetch = deps.fetch; // EXPLICIT — no localhost default. `null` means this executor has no OAuth // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. const redirectUri = deps.redirectUri; @@ -678,6 +680,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { scopes: requestedScopes, resource: client.resource ?? undefined, endpointUrlPolicy: deps.endpointUrlPolicy, + fetch, }).pipe( Effect.mapError( (cause) => @@ -854,6 +857,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { code: input.code, resource: client.resource ?? undefined, endpointUrlPolicy: deps.endpointUrlPolicy, + fetch, }).pipe( Effect.mapError( (cause) => From db1ee2c80c7024ebc07ce42778fd01788b2f3ef8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Tue, 23 Jun 2026 09:39:16 -0700 Subject: [PATCH 2/5] Route MCP transports through HttpClient layer --- packages/core/sdk/src/executor.ts | 1 + packages/core/sdk/src/plugin.ts | 1 + packages/plugins/mcp/src/sdk/connection.ts | 102 +++++++++++++++++- packages/plugins/mcp/src/sdk/plugin.test.ts | 34 +++++- packages/plugins/mcp/src/sdk/plugin.ts | 15 ++- .../plugins/openapi/src/sdk/spec-blob.test.ts | 1 + 6 files changed, 148 insertions(+), 6 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 9e0ad63aa..38e12a3c6 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1920,6 +1920,7 @@ export const createExecutor = { * facades (e.g. a content-addressed spec blob) instead of inlining them * in `config`. */ readonly storage: TStore; + readonly httpClientLayer: Layer.Layer; /** The connection whose tools are being resolved. */ readonly connection: ConnectionRef; /** Which of the integration's declared auth methods the connection binds diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index f629c594d..1e1e2567b 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -2,8 +2,10 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; -import { Effect, Predicate } from "effect"; +import { Effect, Layer, Predicate, Stream } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module // (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process` @@ -42,6 +44,7 @@ export type RemoteConnectorInput = Omit< readonly headers?: Record; readonly queryParams?: Record; readonly authProvider?: OAuthClientProvider; + readonly httpClientLayer?: Layer.Layer; }; export type StdioConnectorInput = McpStdioIntegrationConfig; @@ -60,6 +63,100 @@ const buildEndpointUrl = (endpoint: string, queryParams: Record) return url; }; +type HttpMethod = Parameters[0]; +const HTTP_METHODS = new Set([ + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", +]); + +const httpMethodFrom = (method: string | undefined): HttpMethod => { + const normalized = (method ?? "GET").toUpperCase() as HttpMethod; + return HTTP_METHODS.has(normalized) ? normalized : "POST"; +}; + +const headersFrom = (headers: HeadersInit | undefined): Headers => + headers ? new Headers(headers) : new Headers(); + +const recordFromHeaders = (headers: Headers): Record => + Object.fromEntries(headers.entries()); + +const applyBody = async ( + request: HttpClientRequest.HttpClientRequest, + headers: Headers, + body: BodyInit | null | undefined, +): Promise => { + if (body == null) return request; + const contentType = headers.get("content-type") ?? undefined; + if (typeof body === "string") return HttpClientRequest.bodyText(request, body, contentType); + if (body instanceof URLSearchParams) { + return HttpClientRequest.bodyText( + request, + body.toString(), + contentType ?? "application/x-www-form-urlencoded;charset=UTF-8", + ); + } + if (body instanceof Uint8Array) + return HttpClientRequest.bodyUint8Array(request, body, contentType); + if (body instanceof ArrayBuffer) { + return HttpClientRequest.bodyUint8Array(request, new Uint8Array(body), contentType); + } + const bytes = new Uint8Array(await new Response(body).arrayBuffer()); + return HttpClientRequest.bodyUint8Array(request, bytes, contentType); +}; + +const abortError = (signal: AbortSignal): unknown => { + if (signal.reason !== undefined) return signal.reason; + // oxlint-disable-next-line executor/no-error-constructor -- boundary: Fetch-compatible adapter must reject with an AbortError-shaped value + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + return error; +}; + +const fetchFromHttpClientLayer = ( + httpClientLayer: Layer.Layer, +): FetchLike => { + const execute: FetchLike = async (url, init) => { + const headers = headersFrom(init?.headers); + const requestWithoutBody = HttpClientRequest.make(httpMethodFrom(init?.method))(url, { + headers: recordFromHeaders(headers), + }); + const request = await applyBody(requestWithoutBody, headers, init?.body); + const effect = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute(request); + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(response.headers)) { + if (value !== undefined) responseHeaders.set(key, value); + } + const body = + response.status === 204 || response.status === 205 || response.status === 304 + ? null + : Stream.toReadableStream(response.stream); + return new Response(body, { + status: response.status, + headers: responseHeaders, + }); + }).pipe(Effect.provide(httpClientLayer)); + const promise = Effect.runPromise(effect); + if (!init?.signal) return promise; + // oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter mirrors abort rejection semantics + if (init.signal.aborted) return Promise.reject(abortError(init.signal)); + const aborted = new Promise((_, reject) => { + // oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter races the Effect request against AbortSignal + init.signal?.addEventListener("abort", () => reject(abortError(init.signal!)), { + once: true, + }); + }); + return Promise.race([promise, aborted]); + }; + return execute; +}; + // Use the cfworker JSON Schema validator instead of the SDK's default // (Ajv). Ajv compiles schemas via `new Function(...)`, which throws // `Code generation from strings disallowed for this context` when the @@ -157,6 +254,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { const headers = input.headers ?? {}; const remoteTransport = input.remoteTransport ?? "auto"; const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; + const fetch = input.httpClientLayer ? fetchFromHttpClientLayer(input.httpClientLayer) : undefined; const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); @@ -166,6 +264,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { new StreamableHTTPClientTransport(endpoint, { requestInit, authProvider: input.authProvider, + fetch, }), }); @@ -175,6 +274,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { new SSEClientTransport(endpoint, { requestInit, authProvider: input.authProvider, + fetch, }), }); diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 9d0b0cb3d..2e6001bbf 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Predicate, Schema } from "effect"; -import { HttpServerResponse } from "effect/unstable/http"; +import { Effect, Layer, Option, Predicate, Schema } from "effect"; +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpServerResponse, +} from "effect/unstable/http"; import { AuthTemplateSlug, @@ -17,6 +22,7 @@ import { serveTestHttpApp, } from "@executor-js/sdk/testing"; +import { createMcpConnector } from "./connection"; import { mcpPlugin, userFacingProbeMessage } from "./plugin"; import { McpInvocationError } from "./errors"; import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; @@ -299,6 +305,30 @@ describe("mcpPlugin", () => { }), ); + it.effect("routes remote connector traffic through the provided HttpClient layer", () => + Effect.gen(function* () { + const seen: string[] = []; + const httpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + seen.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("blocked", { status: 403 })), + ); + }), + ); + + const error = yield* createMcpConnector({ + transport: "remote", + endpoint: "https://internal.example/mcp", + remoteTransport: "streamable-http", + httpClientLayer, + }).pipe(Effect.flip); + + expect(Predicate.isTagged(error, "McpConnectionError")).toBe(true); + expect(seen).toEqual(["https://internal.example/mcp"]); + }), + ); + it.effect("integration catalog has no configured MCP integrations initially", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index dd0228da2..d676efc86 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -469,6 +469,7 @@ const buildConnectorInput = ( values: Record, templateSlug: string | null, allowStdio: boolean, + httpClientLayer?: Layer.Layer, ): Effect.Effect => { if (config.transport === "stdio") { if (!allowStdio) { @@ -512,6 +513,7 @@ const buildConnectorInput = ( queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, headers: Object.keys(headers).length > 0 ? headers : undefined, authProvider, + httpClientLayer, }); }; @@ -636,6 +638,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { endpoint: trimmed, headers: probeHeaders, queryParams: probeQueryParams, + httpClientLayer, }); const result = yield* discoverTools(connector).pipe( @@ -929,7 +932,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // Discovery failures (auth not ready, server down) yield an empty tool set // rather than failing — the connection still lands and can be refreshed. // ----------------------------------------------------------------------- - resolveTools: ({ config, connection, template, getValues }) => + resolveTools: ({ config, connection, template, getValues, httpClientLayer }) => Effect.gen(function* () { const parsed = parseMcpIntegrationConfig(config); if (!parsed) return { tools: [] as readonly ToolDef[] }; @@ -945,6 +948,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { values, template === null ? null : String(template), allowStdio, + httpClientLayer, ).pipe( Effect.map((ci) => createMcpConnector(ci)), Effect.result, @@ -968,7 +972,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { }), ) as Effect.Effect<{ readonly tools: readonly ToolDef[] }, StorageFailure>, - invokeTool: ({ toolRow, credential, args, elicit }) => + invokeTool: ({ ctx, toolRow, credential, args, elicit }) => Effect.gen(function* () { const parsed = parseMcpIntegrationConfig(credential.config); if (!parsed) { @@ -1013,6 +1017,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { credential.values, String(credential.template), allowStdio, + options?.httpClientLayer ?? ctx.httpClientLayer, ).pipe(Effect.map((ci) => createMcpConnector(ci))); const raw = yield* invokeMcpTool({ @@ -1087,7 +1092,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const name = parsed.value.hostname || "mcp"; const slug = deriveMcpNamespace({ endpoint: trimmed }); - const connector = createMcpConnector({ transport: "remote", endpoint: trimmed }); + const connector = createMcpConnector({ + transport: "remote", + endpoint: trimmed, + httpClientLayer, + }); const connected = yield* discoverTools(connector).pipe( Effect.map(() => true), diff --git a/packages/plugins/openapi/src/sdk/spec-blob.test.ts b/packages/plugins/openapi/src/sdk/spec-blob.test.ts index 04fecd120..186050633 100644 --- a/packages/plugins/openapi/src/sdk/spec-blob.test.ts +++ b/packages/plugins/openapi/src/sdk/spec-blob.test.ts @@ -161,6 +161,7 @@ describe("OpenAPI plugin — spec blob storage", () => { }, template: null, storage, + httpClientLayer: FetchHttpClient.layer, getValue: () => Effect.succeed(null), getValues: () => Effect.succeed({}), }); From 5865d0cc0c043964e69ce5e5525364f04aab976f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Tue, 23 Jun 2026 09:41:21 -0700 Subject: [PATCH 3/5] Use executor HTTP layer for GraphQL discovery --- .../plugins/graphql/src/sdk/plugin.test.ts | 52 ++++++++++++++++++- packages/plugins/graphql/src/sdk/plugin.ts | 12 ++--- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 030466168..44531c8dd 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "@effect/vitest"; -import { Effect } from "effect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; import { AuthTemplateSlug, @@ -318,6 +324,48 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("uses the executor HttpClient layer for connection-time introspection", () => + Effect.gen(function* () { + const seen: string[] = []; + const httpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + seen.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ data: introspectionResult }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }), + ); + const config = makeTestConfig({ + plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const, + }); + const executor = yield* createExecutor({ ...config, httpClientLayer }); + + yield* executor.graphql.addIntegration({ + endpoint: "https://internal.example/graphql", + slug: "guarded_graph", + name: "Guarded Graph", + }); + yield* createOrgConnection(executor, { + integration: "guarded_graph", + name: "default", + template: "none", + value: "unused", + }); + + const tools = yield* executor.tools.list(); + expect(seen).toEqual(["https://internal.example/graphql"]); + expect(tools.map((tool) => String(tool.name))).toEqual( + expect.arrayContaining(["query.hello", "mutation.setGreeting"]), + ); + }), + ); + it.effect("invokes a live query through an apiKey header template", () => Effect.gen(function* () { const server = yield* serveGreetingServer; diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 0a2eb7d7d..31424b8f8 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -1,6 +1,6 @@ import { Effect, Match, Option, Schema } from "effect"; import type { Layer } from "effect"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { HttpClient } from "effect/unstable/http"; import { authToolFailure, @@ -893,11 +893,13 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { template, storage, getValues, + httpClientLayer, }: { readonly config: IntegrationConfig; readonly template: AuthTemplateSlug | null; readonly storage: GraphqlStore; readonly getValues: () => Effect.Effect, unknown>; + readonly httpClientLayer: Layer.Layer; }) => Effect.gen(function* () { const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option); @@ -917,7 +919,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { introspectionJson, values, template, - options?.httpClientLayer ?? httpClientLayerFallback, + options?.httpClientLayer ?? httpClientLayer, ).pipe(Effect.option); if (Option.isNone(introspection)) return { tools: [] }; const extracted = yield* extract(introspection.value).pipe(Effect.option); @@ -1117,9 +1119,3 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { // HTTP transport (routes/handlers/extensionService) is layered on by the // api-aware factory in `@executor-js/plugin-graphql/api`. }); - -// The fallback HTTP layer for `resolveTools`. The hook input carries no `ctx`, -// so when no explicit layer is passed to the plugin we use the same default the -// executor wires into `ctx.httpClientLayer` (`FetchHttpClient.layer`). Hosts/ -// tests that need a custom transport pass `options.httpClientLayer`. -const httpClientLayerFallback: Layer.Layer = FetchHttpClient.layer; From f275c2c2005f65d47ee6a95ae1ce0e1d5ef24e62 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Tue, 23 Jun 2026 09:49:11 -0700 Subject: [PATCH 4/5] Restrict Google Discovery bundle URLs --- .../plugins/google/src/sdk/discovery.test.ts | 27 +++++++ packages/plugins/google/src/sdk/discovery.ts | 70 ++++++++++++++----- .../plugins/google/src/sdk/plugin.test.ts | 39 ++++++++++- packages/plugins/google/src/sdk/plugin.ts | 13 ++-- packages/plugins/google/src/sdk/presets.ts | 4 ++ 5 files changed, 130 insertions(+), 23 deletions(-) diff --git a/packages/plugins/google/src/sdk/discovery.test.ts b/packages/plugins/google/src/sdk/discovery.test.ts index 233787cf9..f5608cec4 100644 --- a/packages/plugins/google/src/sdk/discovery.test.ts +++ b/packages/plugins/google/src/sdk/discovery.test.ts @@ -5,6 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; import { convertGoogleDiscoveryBundleToOpenApi, convertGoogleDiscoveryToOpenApi, + isGoogleDiscoveryUrl, + normalizeGoogleDiscoveryUrl, } from "./discovery"; import { extract, parse } from "@executor-js/plugin-openapi"; @@ -44,6 +46,31 @@ const ConvertedSpec = Schema.Struct({ const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); +it("accepts only supported HTTPS Google Discovery endpoints", () => { + expect( + normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"), + ).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"); + expect( + normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"), + ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest"); + + expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( + true, + ); + expect(isGoogleDiscoveryUrl("https://evilgoogleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( + false, + ); + expect(isGoogleDiscoveryUrl("http://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( + false, + ); + expect( + isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest?next=x"), + ).toBe(false); + expect( + isGoogleDiscoveryUrl("https://token@www.googleapis.com/discovery/v1/apis/gmail/v1/rest"), + ).toBe(false); +}); + const normalizeOpenApiRefsForPreview = (node: unknown): unknown => { if (node == null || typeof node !== "object") return node; if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview); diff --git a/packages/plugins/google/src/sdk/discovery.ts b/packages/plugins/google/src/sdk/discovery.ts index bd786fef2..4bc485af7 100644 --- a/packages/plugins/google/src/sdk/discovery.ts +++ b/packages/plugins/google/src/sdk/discovery.ts @@ -231,38 +231,76 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); -const normalizeDiscoveryUrl = (discoveryUrl: string): string => { - const trimmed = discoveryUrl.trim(); - if (!URL.canParse(trimmed)) return trimmed; - const parsed = new URL(trimmed); - if (parsed.pathname !== "/$discovery/rest") return trimmed; - const version = parsed.searchParams.get("version")?.trim(); - if (!version) return trimmed; - const host = parsed.hostname.toLowerCase(); - if (!host.endsWith(".googleapis.com")) return trimmed; +const DISCOVERY_SERVICE_PATH_RE = + /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/; +const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/; + +const serviceFromGoogleApisHost = (host: string): string | null => { + if (!host.endsWith(".googleapis.com")) return null; const rawService = host.slice(0, -".googleapis.com".length); + if (!rawService || rawService.includes(".")) return null; const service = rawService === "calendar-json" ? "calendar" : rawService.endsWith("-json") ? rawService.slice(0, -5) : rawService; - return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed; + return /^[a-z0-9][a-z0-9-]*$/.test(service) ? service : null; }; -export const isGoogleDiscoveryUrl = (url: string): boolean => { - const trimmed = url.trim(); - if (!URL.canParse(trimmed)) return false; +export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null => { + const trimmed = discoveryUrl.trim(); + if (!URL.canParse(trimmed)) return null; const parsed = new URL(trimmed); + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) { + return null; + } + const host = parsed.hostname.toLowerCase(); - if (!host.endsWith("googleapis.com")) return false; - return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); + if (host === "www.googleapis.com") { + if (parsed.search) return null; + const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE); + const service = match?.[1]; + const version = match?.[2]; + return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null; + } + + const service = serviceFromGoogleApisHost(host); + if (!service || !["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) { + return null; + } + const keys = [...parsed.searchParams.keys()]; + const version = parsed.searchParams.get("version")?.trim(); + if ( + keys.length !== 1 || + keys[0] !== "version" || + !version || + !DISCOVERY_VERSION_RE.test(version) + ) { + return null; + } + return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; +}; + +const normalizeDiscoveryUrl = (discoveryUrl: string): string => { + return normalizeGoogleDiscoveryUrl(discoveryUrl) ?? discoveryUrl.trim(); +}; + +export const isGoogleDiscoveryUrl = (url: string): boolean => { + return normalizeGoogleDiscoveryUrl(url) !== null; }; export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { + const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl); + if (!normalizedDiscoveryUrl) { + return yield* new OpenApiParseError({ + message: + "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", + }); + } const client = yield* HttpClient.HttpClient; - const requestUrl = new URL(discoveryUrl); + const requestUrl = new URL(normalizedDiscoveryUrl); for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { requestUrl.searchParams.set(name, value); } diff --git a/packages/plugins/google/src/sdk/plugin.test.ts b/packages/plugins/google/src/sdk/plugin.test.ts index e4266cc78..a0df2b661 100644 --- a/packages/plugins/google/src/sdk/plugin.test.ts +++ b/packages/plugins/google/src/sdk/plugin.test.ts @@ -12,7 +12,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { @@ -174,6 +174,43 @@ const bundlePlugins = () => [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; describe("Google bundle add flow", () => { + it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () => + Effect.scoped( + Effect.gen(function* () { + let requests = 0; + const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + requests += 1; + return HttpClientResponse.fromWeb( + request, + new Response("unexpected request", { status: 500 }), + ); + }), + ), + ); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + googlePlugin({ httpClientLayer: blockedHttpClientLayer }), + memoryCredentialsPlugin(), + ], + }), + ); + + const exit = yield* executor.google + .addBundle({ + urls: ["https://evilgoogleapis.com/discovery/v1/apis/calendar/v3/rest"], + slug: "bad_google", + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(requests).toBe(0); + }), + ), + ); + it.effect( "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions", () => diff --git a/packages/plugins/google/src/sdk/plugin.ts b/packages/plugins/google/src/sdk/plugin.ts index fdeb93422..597a30b96 100644 --- a/packages/plugins/google/src/sdk/plugin.ts +++ b/packages/plugins/google/src/sdk/plugin.ts @@ -33,7 +33,7 @@ import { import { convertGoogleDiscoveryBundleToOpenApi, fetchGoogleDiscoveryDocument, - isGoogleDiscoveryUrl, + normalizeGoogleDiscoveryUrl, } from "./discovery"; import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; import { googleOpenApiBundlePreset } from "./presets"; @@ -83,7 +83,7 @@ const fetchGoogleBundleConversion = ( ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents }))); const uniqueUrls = (urls: readonly string[]): readonly string[] => [ - ...new Set(urls.map((url) => url.trim()).filter((url) => url.length > 0)), + ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])), ]; const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { @@ -326,13 +326,14 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ detect: ({ ctx, url }) => Effect.gen(function* () { const trimmed = url.trim(); - if (!trimmed || !isGoogleDiscoveryUrl(trimmed)) return null; + const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed); + if (!trimmed || !discoveryUrl) return null; const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; - const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe( + const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe( Effect.provide(httpClientLayer), Effect.flatMap((documentText) => convertGoogleDiscoveryBundleToOpenApi({ - documents: [{ discoveryUrl: trimmed, documentText }], + documents: [{ discoveryUrl, documentText }], }), ), Effect.catch(() => Effect.succeed(null)), @@ -341,7 +342,7 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ return IntegrationDetectionResult.make({ kind: "google", confidence: "high", - endpoint: trimmed, + endpoint: discoveryUrl, name: conversion.title, slug: DEFAULT_GOOGLE_SLUG, }); diff --git a/packages/plugins/google/src/sdk/presets.ts b/packages/plugins/google/src/sdk/presets.ts index 992dfc327..e369c369d 100644 --- a/packages/plugins/google/src/sdk/presets.ts +++ b/packages/plugins/google/src/sdk/presets.ts @@ -1,3 +1,5 @@ +import { normalizeGoogleDiscoveryUrl } from "./discovery"; + export interface GooglePreset { readonly id: string; readonly name: string; @@ -242,6 +244,8 @@ export const googleOAuthConsentScopesForPreset = (presetId: string): readonly st // --------------------------------------------------------------------------- const normalizeGooglePresetUrl = (url: string): string => { + const discoveryUrl = normalizeGoogleDiscoveryUrl(url); + if (discoveryUrl) return discoveryUrl; const trimmed = url.trim(); if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); const parsed = new URL(trimmed); From 0cf12bc33f784aec85e71b952845260b97049e83 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Tue, 23 Jun 2026 09:51:24 -0700 Subject: [PATCH 5/5] Pin Microsoft Graph provider URLs --- packages/plugins/microsoft/src/sdk/graph.ts | 160 +++++++++++++++++- .../plugins/microsoft/src/sdk/plugin.test.ts | 56 +++++- packages/plugins/microsoft/src/sdk/plugin.ts | 10 +- 3 files changed, 217 insertions(+), 9 deletions(-) diff --git a/packages/plugins/microsoft/src/sdk/graph.ts b/packages/plugins/microsoft/src/sdk/graph.ts index 6899d2ab6..d9d89cd96 100644 --- a/packages/plugins/microsoft/src/sdk/graph.ts +++ b/packages/plugins/microsoft/src/sdk/graph.ts @@ -61,6 +61,10 @@ export interface MicrosoftGraphSpecBuild { readonly authenticationTemplate: readonly Authentication[]; } +export interface MicrosoftGraphUrlPolicy { + readonly allowUnsafeUrlOverrides?: boolean; +} + export type MicrosoftGraphIntegrationConfig = OpenApiIntegrationConfig & { readonly microsoftGraphPresetIds?: readonly string[]; readonly microsoftGraphCustomScopes?: readonly string[]; @@ -180,6 +184,154 @@ const BASE_OAUTH_SCOPES = new Set(["offline_access", "openid", "profile", "email const firstString = (values: readonly unknown[]): string | undefined => values.find((value): value is string => typeof value === "string" && value.trim().length > 0); +const parseTrustedHttpsUrl = (value: string): URL | null => { + if (!URL.canParse(value)) return null; + const parsed = new URL(value); + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) { + return null; + } + return parsed; +}; + +const allowUnsafeUrl = ( + value: string | undefined, + policy: MicrosoftGraphUrlPolicy | undefined, +): string | undefined | null => { + if (!value) return undefined; + if (policy?.allowUnsafeUrlOverrides !== true) return null; + return parseTrustedHttpsUrl(value) ? value : null; +}; + +const normalizeMicrosoftGraphSpecUrl = ( + value: string, + policy?: MicrosoftGraphUrlPolicy, +): string | null => { + if (value === MICROSOFT_GRAPH_OPENAPI_URL) return value; + return allowUnsafeUrl(value, policy) ?? null; +}; + +const MICROSOFT_GRAPH_HOSTS = new Set([ + "graph.microsoft.com", + "graph.microsoft.us", + "dod-graph.microsoft.us", + "microsoftgraph.chinacloudapi.cn", +]); + +const normalizeMicrosoftGraphBaseUrl = ( + value: string | undefined, + policy?: MicrosoftGraphUrlPolicy, +): string | undefined | null => { + const unsafe = allowUnsafeUrl(value, policy); + if (unsafe !== null) return unsafe; + if (!value) return undefined; + const parsed = parseTrustedHttpsUrl(value); + if (!parsed || !MICROSOFT_GRAPH_HOSTS.has(parsed.hostname.toLowerCase())) return null; + if (!/^\/(?:v1\.0|beta)(?:\/)?$/.test(parsed.pathname)) return null; + if (parsed.search) return null; + return parsed.toString().replace(/\/$/, ""); +}; + +const MICROSOFT_IDENTITY_HOSTS = new Set([ + "login.microsoftonline.com", + "login.microsoftonline.us", + "login.partner.microsoftonline.cn", +]); + +const normalizeMicrosoftOAuthEndpointUrl = ( + value: string, + endpoint: "authorize" | "token", + policy?: MicrosoftGraphUrlPolicy, +): string | null => { + const unsafe = allowUnsafeUrl(value, policy); + if (unsafe !== null) return unsafe ?? null; + const parsed = parseTrustedHttpsUrl(value); + if (!parsed || !MICROSOFT_IDENTITY_HOSTS.has(parsed.hostname.toLowerCase())) return null; + if (parsed.search) return null; + const suffix = endpoint === "authorize" ? "authorize" : "token"; + return /^\/[^/]+\/oauth2\/v2\.0\/(?:authorize|token)$/.test(parsed.pathname) && + parsed.pathname.endsWith(`/${suffix}`) + ? parsed.toString() + : null; +}; + +const validateSelectionUrls = ( + selection: ReturnType, + policy?: MicrosoftGraphUrlPolicy, +): Effect.Effect, OpenApiParseError> => + Effect.gen(function* () { + const specUrl = normalizeMicrosoftGraphSpecUrl(selection.specUrl, policy); + if (!specUrl) { + return yield* new OpenApiParseError({ + message: "Microsoft Graph specUrl must point to the trusted Microsoft Graph OpenAPI source", + }); + } + const baseUrl = normalizeMicrosoftGraphBaseUrl(selection.baseUrl, policy); + if (baseUrl === null) { + return yield* new OpenApiParseError({ + message: "Microsoft Graph baseUrl must point to a supported Microsoft Graph endpoint", + }); + } + const authorizationUrl = selection.authorizationUrl + ? normalizeMicrosoftOAuthEndpointUrl(selection.authorizationUrl, "authorize", policy) + : undefined; + if (selection.authorizationUrl && !authorizationUrl) { + return yield* new OpenApiParseError({ + message: "Microsoft authorizationUrl must point to a supported Microsoft identity endpoint", + }); + } + const tokenUrl = selection.tokenUrl + ? normalizeMicrosoftOAuthEndpointUrl(selection.tokenUrl, "token", policy) + : undefined; + if (selection.tokenUrl && !tokenUrl) { + return yield* new OpenApiParseError({ + message: "Microsoft tokenUrl must point to a supported Microsoft identity endpoint", + }); + } + const clientCredentialsTokenUrl = selection.clientCredentialsTokenUrl + ? normalizeMicrosoftOAuthEndpointUrl(selection.clientCredentialsTokenUrl, "token", policy) + : undefined; + if (selection.clientCredentialsTokenUrl && !clientCredentialsTokenUrl) { + return yield* new OpenApiParseError({ + message: + "Microsoft clientCredentialsTokenUrl must point to a supported Microsoft identity endpoint", + }); + } + return { + ...selection, + specUrl, + ...(baseUrl ? { baseUrl } : { baseUrl: undefined }), + ...(authorizationUrl ? { authorizationUrl } : { authorizationUrl: undefined }), + ...(tokenUrl ? { tokenUrl } : { tokenUrl: undefined }), + ...(clientCredentialsTokenUrl + ? { clientCredentialsTokenUrl } + : { clientCredentialsTokenUrl: undefined }), + }; + }); + +const validateResolvedOAuthEndpoints = ( + endpoints: MicrosoftOAuthEndpoints, + policy?: MicrosoftGraphUrlPolicy, +): Effect.Effect => + Effect.gen(function* () { + const authorizationUrl = normalizeMicrosoftOAuthEndpointUrl( + endpoints.authorizationUrl, + "authorize", + policy, + ); + const tokenUrl = normalizeMicrosoftOAuthEndpointUrl(endpoints.tokenUrl, "token", policy); + const clientCredentialsTokenUrl = normalizeMicrosoftOAuthEndpointUrl( + endpoints.clientCredentialsTokenUrl, + "token", + policy, + ); + if (!authorizationUrl || !tokenUrl || !clientCredentialsTokenUrl) { + return yield* new OpenApiParseError({ + message: "Microsoft OAuth endpoints must point to supported Microsoft identity endpoints", + }); + } + return { authorizationUrl, tokenUrl, clientCredentialsTokenUrl }; + }); + const recordValues = (value: unknown): readonly unknown[] => isRecord(value) ? Object.values(value) : []; @@ -490,9 +642,10 @@ const streamSelectedScopes = ( export const buildMicrosoftGraphOpenApiSpec = ( input: MicrosoftGraphSelectionInput, httpClientLayer: Layer.Layer, + urlPolicy?: MicrosoftGraphUrlPolicy, ): Effect.Effect => Effect.gen(function* () { - const selection = normalizeSelection(input); + const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy); const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( Effect.provide(httpClientLayer), ); @@ -512,7 +665,10 @@ export const buildMicrosoftGraphOpenApiSpec = ( // Head + small components (servers + securitySchemes) parse cheaply and // carry everything `resolveOAuthEndpoints` needs. const headDoc = { ...parseHead(structure), components: parseSmallComponents(structure) }; - const endpoints = resolveOAuthEndpoints(headDoc, selection); + const endpoints = yield* validateResolvedOAuthEndpoints( + resolveOAuthEndpoints(headDoc, selection), + urlPolicy, + ); const permissionsReference = selection.coversFullGraph === true diff --git a/packages/plugins/microsoft/src/sdk/plugin.test.ts b/packages/plugins/microsoft/src/sdk/plugin.test.ts index d5b6042a5..fc621ed9e 100644 --- a/packages/plugins/microsoft/src/sdk/plugin.test.ts +++ b/packages/plugins/microsoft/src/sdk/plugin.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { @@ -10,7 +10,7 @@ import { } from "@executor-js/sdk"; import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; -import { microsoftPlugin } from "./plugin"; +import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin"; import { MICROSOFT_AUTH_TEMPLATE_SLUG, MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, @@ -191,10 +191,54 @@ const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( ), ); -const graphPlugins = () => - [microsoftPlugin({ httpClientLayer: graphHttpClientLayer }), memoryCredentialsPlugin()] as const; +const graphPlugins = (options?: Omit) => + [ + microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }), + memoryCredentialsPlugin(), + ] as const; describe("Microsoft Graph provider", () => { + it.effect("rejects non-Microsoft URL overrides before fetching the Graph spec", () => + Effect.scoped( + Effect.gen(function* () { + let requests = 0; + const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + requests += 1; + return HttpClientResponse.fromWeb( + request, + new Response("unexpected request", { status: 500 }), + ); + }), + ), + ); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + microsoftPlugin({ httpClientLayer: blockedHttpClientLayer }), + memoryCredentialsPlugin(), + ], + }), + ); + + const exit = yield* executor.microsoft + .addGraph({ + slug: "bad_graph", + baseUrl: "https://attacker.example/v1.0", + specUrl: "https://attacker.example/openapi.yaml", + authorizationUrl: "https://attacker.example/oauth2/v2.0/authorize", + tokenUrl: "https://attacker.example/oauth2/v2.0/token", + clientCredentialsTokenUrl: "https://attacker.example/oauth2/v2.0/token", + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(requests).toBe(0); + }), + ), + ); + it.effect("adds a selected Graph workload source with one OAuth template", () => Effect.scoped( Effect.gen(function* () { @@ -414,7 +458,9 @@ describe("Microsoft Graph provider", () => { it.effect("adds Microsoft Graph from the emulator spec with app-only OAuth endpoints", () => Effect.scoped( Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }), + ); yield* executor.microsoft.addGraph({ presetIds: ["users"], diff --git a/packages/plugins/microsoft/src/sdk/plugin.ts b/packages/plugins/microsoft/src/sdk/plugin.ts index cdf28ec6d..854b4396b 100644 --- a/packages/plugins/microsoft/src/sdk/plugin.ts +++ b/packages/plugins/microsoft/src/sdk/plugin.ts @@ -37,6 +37,7 @@ import { buildMicrosoftGraphOpenApiSpec, decodeMicrosoftGraphIntegrationConfig, microsoftGraphKeepPathItem, + type MicrosoftGraphUrlPolicy, type MicrosoftGraphIntegrationConfig, type MicrosoftGraphSpecBuild, } from "./graph"; @@ -83,6 +84,7 @@ export interface MicrosoftUpdateResult { export interface MicrosoftPluginOptions { readonly httpClientLayer?: Layer.Layer; + readonly allowUnsafeUrlOverrides?: boolean; } const DEFAULT_MICROSOFT_SLUG = "microsoft_graph"; @@ -124,6 +126,7 @@ const describeMicrosoftIntegrationDisplay = ( const makeMicrosoftPluginExtension = ( ctx: PluginCtx, httpClientLayer: Layer.Layer, + urlPolicy?: MicrosoftGraphUrlPolicy, ) => { const persistGraphOperations = ( graph: MicrosoftGraphSpecBuild, @@ -145,7 +148,7 @@ const makeMicrosoftPluginExtension = ( const addGraph = (config: MicrosoftGraphConfig) => Effect.gen(function* () { - const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer); + const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer, urlPolicy); const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_MICROSOFT_SLUG); const existing = yield* ctx.core.integrations.get(slug); @@ -212,6 +215,7 @@ const makeMicrosoftPluginExtension = ( input?.clientCredentialsTokenUrl ?? current.microsoftGraphClientCredentialsTokenUrl, }, httpClientLayer, + urlPolicy, ); const previousOperations = yield* ctx.storage.listOperations(rawSlug); const previousNames = new Set(previousOperations.map((op) => op.toolName)); @@ -341,7 +345,9 @@ export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) = storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), extension: (ctx) => - makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer), + makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, { + allowUnsafeUrlOverrides: options?.allowUnsafeUrlOverrides === true, + }), describeAuthMethods: describeMicrosoftAuthMethods, describeIntegrationDisplay: describeMicrosoftIntegrationDisplay,