Skip to content

Commit c160b48

Browse files
authored
Batch remaining hosted egress hardening fixes (#1106)
* Route OAuth token fetches through hosted guard * Route MCP transports through HttpClient layer * Use executor HTTP layer for GraphQL discovery * Restrict Google Discovery bundle URLs * Pin Microsoft Graph provider URLs
1 parent d04af9c commit c160b48

23 files changed

Lines changed: 663 additions & 58 deletions

packages/core/api/src/server/scoped-executor.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
type Executor,
4242
type StorageFailure,
4343
} from "@executor-js/sdk";
44-
import { makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal";
44+
import { makeHostedFetch, makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal";
4545

4646
import { DbProvider } from "./executor-fuma-db";
4747

@@ -241,9 +241,11 @@ export const makeScopedExecutor = <
241241
});
242242

243243
const plugins = pluginsFactory();
244-
const httpClientLayer = makeHostedHttpClientLayer({
244+
const hostedHttpOptions = {
245245
allowLocalNetwork: config.allowLocalNetwork,
246-
});
246+
};
247+
const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions);
248+
const hostedFetch = makeHostedFetch(hostedHttpOptions);
247249

248250
// The org id is the tenant (catalog partition); the account id is the acting
249251
// subject (drives `owner: "user"` rows). `organizationName` is no longer part
@@ -255,6 +257,7 @@ export const makeScopedExecutor = <
255257
blobs,
256258
plugins,
257259
httpClientLayer,
260+
fetch: hostedFetch,
258261
onElicitation: "accept-all",
259262
redirectUri,
260263
coreTools: {

packages/core/sdk/src/executor.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,11 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly
357357
*/
358358
readonly onElicitation: OnElicitation;
359359
readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
360+
/**
361+
* Fetch API implementation for dependencies that cannot consume `httpClientLayer`.
362+
* Prefer `httpClientLayer` for normal SDK and plugin HTTP.
363+
*/
364+
readonly fetch?: typeof globalThis.fetch;
360365
/**
361366
* The OAuth callback URL (`${webBaseUrl}/oauth/callback`) the host serves and
362367
* sends to providers. There is NO localhost default: omit it (or pass
@@ -1455,6 +1460,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
14551460
scopes: grantedScopes,
14561461
resource: clientRow.resource ? String(clientRow.resource) : undefined,
14571462
endpointUrlPolicy: config.oauthEndpointUrlPolicy,
1463+
fetch: config.fetch,
14581464
}).pipe(
14591465
// A client_credentials failure is never a rotated-refresh-token
14601466
// problem, so do NOT map invalid_grant → reauth. Surface as a
@@ -1487,6 +1493,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
14871493
// (MCP servers require this on refresh).
14881494
resource: clientRow.resource ? String(clientRow.resource) : undefined,
14891495
endpointUrlPolicy: config.oauthEndpointUrlPolicy,
1496+
fetch: config.fetch,
14901497
}).pipe(
14911498
Effect.mapError((cause) =>
14921499
cause.error === "invalid_grant"
@@ -1913,6 +1920,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19131920
.resolveTools({
19141921
integration: rowToIntegration(integrationRow),
19151922
config: decodeJsonColumn(integrationRow.config),
1923+
httpClientLayer: runtime.ctx.httpClientLayer,
19161924
connection: ref,
19171925
template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,
19181926
storage: runtime.storage,
@@ -3079,6 +3087,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
30793087
}),
30803088
),
30813089
httpClientLayer: config.httpClientLayer,
3090+
fetch: config.fetch,
30823091
endpointUrlPolicy: config.oauthEndpointUrlPolicy,
30833092
// EXPLICIT — no localhost default. When a caller omits `redirectUri` the
30843093
// OAuth service receives `null` and redirect-requiring flows fail loudly

packages/core/sdk/src/host-internal.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
// exists so the host layer (`@executor-js/api/server`) can reach SDK-resident
77
// host machinery without that machinery polluting the plugin-author surface:
88
//
9-
// - `makeHostedHttpClientLayer` / `HostedOutboundRequestBlocked`: the hosted
10-
// HTTP client builder. `validateHostedOutboundUrl` is consumed by
11-
// `createExecutor`'s built-in `fetch` tool (the SSRF guard), which is why
12-
// the module stays in the SDK rather than moving wholesale to the host.
9+
// - `makeHostedHttpClientLayer` / `makeHostedFetch` /
10+
// `HostedOutboundRequestBlocked`: the hosted HTTP client builder plus the
11+
// guarded Fetch API adapter for libraries that require fetch. The shared
12+
// outbound URL guard is consumed by `createExecutor`'s built-in `fetch` tool
13+
// (the SSRF guard), which is why the module stays in the SDK rather than
14+
// moving wholesale to the host.
1315
// - `createExecutorFumaDb` + its types: the pure, driver-agnostic FumaDB
1416
// assembly. It stays in the SDK because the SDK's own sqlite test backend
1517
// builds its handle with it; the host layer re-exports it (and pairs it
@@ -22,6 +24,7 @@
2224

2325
export {
2426
HostedOutboundRequestBlocked,
27+
makeHostedFetch,
2528
makeHostedHttpClientLayer,
2629
type HostedHttpClientOptions,
2730
} from "./hosted-http-client";

packages/core/sdk/src/hosted-http-client.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
44

55
import {
66
type HostedHostnameResolver,
7+
makeHostedFetch,
78
makeHostedHttpClientLayer,
89
validateHostedOutboundUrl,
910
} from "./hosted-http-client";
@@ -95,6 +96,22 @@ describe("hosted outbound HTTP client", () => {
9596
}),
9697
);
9798

99+
it("applies the DNS guard to fetch callers", async () => {
100+
let calls = 0;
101+
const hostedFetch = makeHostedFetch({
102+
fetch: (async () => {
103+
calls++;
104+
return new Response("unexpected", { status: 200 });
105+
}) as typeof globalThis.fetch,
106+
resolveHostname: async () => [{ address: "10.0.0.20", family: 4 }],
107+
});
108+
109+
await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({
110+
_tag: "HostedOutboundRequestBlocked",
111+
});
112+
expect(calls).toBe(0);
113+
});
114+
98115
it.effect("checks redirected URLs before following them", () =>
99116
Effect.gen(function* () {
100117
let calls = 0;

packages/core/sdk/src/hosted-http-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ const guardFetch = (
246246
return await underlying(current, { ...currentInit, redirect: "manual" });
247247
}) as typeof globalThis.fetch;
248248

249+
export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch =>
250+
// oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch
251+
guardFetch(options.fetch ?? globalThis.fetch, options);
252+
249253
export const makeHostedHttpClientLayer = (
250254
options: HostedHttpClientOptions = {},
251255
): Layer.Layer<HttpClient.HttpClient> =>

packages/core/sdk/src/oauth-helpers.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,49 @@ describe("exchangeAuthorizationCode", () => {
608608
});
609609

610610
describe("exchangeClientCredentials", () => {
611+
it.effect("routes token grant requests through the injected fetch", () =>
612+
withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl }) =>
613+
Effect.gen(function* () {
614+
const seen: Array<{ url: string; method: string | undefined }> = [];
615+
const customFetch: typeof globalThis.fetch = (async (input, init) => {
616+
seen.push({
617+
url: input instanceof Request ? input.url : String(input),
618+
method: init?.method,
619+
});
620+
// oxlint-disable-next-line executor/no-raw-fetch -- boundary: test fetch adapter delegates to the local token endpoint
621+
return fetch(input, init);
622+
}) as typeof globalThis.fetch;
623+
624+
yield* exchangeAuthorizationCode({
625+
tokenUrl,
626+
clientId: "cid",
627+
redirectUrl: "https://app.example.com/cb",
628+
codeVerifier: "verifier",
629+
code: "abc",
630+
fetch: customFetch,
631+
});
632+
yield* exchangeClientCredentials({
633+
tokenUrl,
634+
clientId: "cid",
635+
clientSecret: "secret",
636+
fetch: customFetch,
637+
});
638+
yield* refreshAccessToken({
639+
tokenUrl,
640+
clientId: "cid",
641+
refreshToken: "old",
642+
fetch: customFetch,
643+
});
644+
645+
expect(seen).toEqual([
646+
{ url: tokenUrl, method: "POST" },
647+
{ url: tokenUrl, method: "POST" },
648+
{ url: tokenUrl, method: "POST" },
649+
]);
650+
}),
651+
),
652+
);
653+
611654
it.effect("rejects unsupported token URL schemes before exchange", () =>
612655
Effect.gen(function* () {
613656
const exit = yield* Effect.exit(

packages/core/sdk/src/oauth-helpers.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -417,10 +417,14 @@ const oauth4webapiRequestOptions = (
417417
targetUrl: string,
418418
timeoutMs: number | undefined,
419419
endpointUrlPolicy: OAuthEndpointUrlPolicy = {},
420+
customFetch?: typeof globalThis.fetch,
420421
): Record<string, unknown> => {
421422
const options: Record<string, unknown> = {
422423
signal: AbortSignal.timeout(timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS),
423424
};
425+
if (customFetch) {
426+
(options as { [oauth.customFetch]?: typeof globalThis.fetch })[oauth.customFetch] = customFetch;
427+
}
424428
if (
425429
isLoopbackHttpUrl(targetUrl) ||
426430
(URL.canParse(targetUrl) &&
@@ -510,6 +514,7 @@ export type ExchangeAuthorizationCodeInput = {
510514
readonly resource?: string;
511515
readonly timeoutMs?: number;
512516
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
517+
readonly fetch?: typeof globalThis.fetch;
513518
};
514519

515520
export const exchangeAuthorizationCode = (
@@ -545,7 +550,12 @@ export const exchangeAuthorizationCode = (
545550
clientAuth,
546551
"authorization_code",
547552
params,
548-
oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
553+
oauth4webapiRequestOptions(
554+
input.tokenUrl,
555+
input.timeoutMs,
556+
input.endpointUrlPolicy,
557+
input.fetch,
558+
),
549559
);
550560
return await processTokenEndpointResponse(as, client, response);
551561
},
@@ -568,6 +578,7 @@ export type ExchangeClientCredentialsInput = {
568578
readonly resource?: string;
569579
readonly timeoutMs?: number;
570580
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
581+
readonly fetch?: typeof globalThis.fetch;
571582
};
572583

573584
export const exchangeClientCredentials = (
@@ -593,7 +604,12 @@ export const exchangeClientCredentials = (
593604
client,
594605
clientAuth,
595606
params,
596-
oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
607+
oauth4webapiRequestOptions(
608+
input.tokenUrl,
609+
input.timeoutMs,
610+
input.endpointUrlPolicy,
611+
input.fetch,
612+
),
597613
);
598614
const result = await oauth.processClientCredentialsResponse(as, client, response);
599615
return tokenResponseFrom(result);
@@ -621,6 +637,7 @@ export type RefreshAccessTokenInput = {
621637
readonly resource?: string;
622638
readonly timeoutMs?: number;
623639
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
640+
readonly fetch?: typeof globalThis.fetch;
624641
};
625642

626643
export const refreshAccessToken = (
@@ -652,7 +669,12 @@ export const refreshAccessToken = (
652669
clientAuth,
653670
input.refreshToken,
654671
{
655-
...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
672+
...oauth4webapiRequestOptions(
673+
input.tokenUrl,
674+
input.timeoutMs,
675+
input.endpointUrlPolicy,
676+
input.fetch,
677+
),
656678
additionalParameters,
657679
},
658680
);

packages/core/sdk/src/oauth-service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export interface OAuthServiceDeps {
132132
template: AuthTemplateSlug,
133133
) => Effect.Effect<readonly string[], StorageFailure>;
134134
readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
135+
readonly fetch?: typeof globalThis.fetch;
135136
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
136137
/**
137138
* The OAuth callback URL (`${webBaseUrl}${mountPrefix}/oauth/callback`) the host
@@ -341,6 +342,7 @@ const validateClientEndpoints = (
341342

342343
export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
343344
const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer;
345+
const fetch = deps.fetch;
344346
// EXPLICIT — no localhost default. `null` means this executor has no OAuth
345347
// callback; redirect-requiring flows fail loudly via `requireRedirectUri`.
346348
const redirectUri = deps.redirectUri;
@@ -678,6 +680,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
678680
scopes: requestedScopes,
679681
resource: client.resource ?? undefined,
680682
endpointUrlPolicy: deps.endpointUrlPolicy,
683+
fetch,
681684
}).pipe(
682685
Effect.mapError(
683686
(cause) =>
@@ -854,6 +857,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
854857
code: input.code,
855858
resource: client.resource ?? undefined,
856859
endpointUrlPolicy: deps.endpointUrlPolicy,
860+
fetch,
857861
}).pipe(
858862
Effect.mapError(
859863
(cause) =>

packages/core/sdk/src/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ export interface ResolveToolsInput<TStore = unknown> {
211211
* facades (e.g. a content-addressed spec blob) instead of inlining them
212212
* in `config`. */
213213
readonly storage: TStore;
214+
readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
214215
/** The connection whose tools are being resolved. */
215216
readonly connection: ConnectionRef;
216217
/** Which of the integration's declared auth methods the connection binds

packages/plugins/google/src/sdk/discovery.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
55
import {
66
convertGoogleDiscoveryBundleToOpenApi,
77
convertGoogleDiscoveryToOpenApi,
8+
isGoogleDiscoveryUrl,
9+
normalizeGoogleDiscoveryUrl,
810
} from "./discovery";
911
import { extract, parse } from "@executor-js/plugin-openapi";
1012

@@ -44,6 +46,31 @@ const ConvertedSpec = Schema.Struct({
4446

4547
const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec));
4648

49+
it("accepts only supported HTTPS Google Discovery endpoints", () => {
50+
expect(
51+
normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"),
52+
).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest");
53+
expect(
54+
normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"),
55+
).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest");
56+
57+
expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
58+
true,
59+
);
60+
expect(isGoogleDiscoveryUrl("https://evilgoogleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
61+
false,
62+
);
63+
expect(isGoogleDiscoveryUrl("http://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
64+
false,
65+
);
66+
expect(
67+
isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest?next=x"),
68+
).toBe(false);
69+
expect(
70+
isGoogleDiscoveryUrl("https://token@www.googleapis.com/discovery/v1/apis/gmail/v1/rest"),
71+
).toBe(false);
72+
});
73+
4774
const normalizeOpenApiRefsForPreview = (node: unknown): unknown => {
4875
if (node == null || typeof node !== "object") return node;
4976
if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview);

0 commit comments

Comments
 (0)