Skip to content

Commit ac0bda3

Browse files
committed
Pin Microsoft Graph provider URLs
1 parent be395f6 commit ac0bda3

3 files changed

Lines changed: 217 additions & 9 deletions

File tree

packages/plugins/microsoft/src/sdk/graph.ts

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export interface MicrosoftGraphSpecBuild {
6161
readonly authenticationTemplate: readonly Authentication[];
6262
}
6363

64+
export interface MicrosoftGraphUrlPolicy {
65+
readonly allowUnsafeUrlOverrides?: boolean;
66+
}
67+
6468
export type MicrosoftGraphIntegrationConfig = OpenApiIntegrationConfig & {
6569
readonly microsoftGraphPresetIds?: readonly string[];
6670
readonly microsoftGraphCustomScopes?: readonly string[];
@@ -180,6 +184,154 @@ const BASE_OAUTH_SCOPES = new Set(["offline_access", "openid", "profile", "email
180184
const firstString = (values: readonly unknown[]): string | undefined =>
181185
values.find((value): value is string => typeof value === "string" && value.trim().length > 0);
182186

187+
const parseTrustedHttpsUrl = (value: string): URL | null => {
188+
if (!URL.canParse(value)) return null;
189+
const parsed = new URL(value);
190+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) {
191+
return null;
192+
}
193+
return parsed;
194+
};
195+
196+
const allowUnsafeUrl = (
197+
value: string | undefined,
198+
policy: MicrosoftGraphUrlPolicy | undefined,
199+
): string | undefined | null => {
200+
if (!value) return undefined;
201+
if (policy?.allowUnsafeUrlOverrides !== true) return null;
202+
return parseTrustedHttpsUrl(value) ? value : null;
203+
};
204+
205+
const normalizeMicrosoftGraphSpecUrl = (
206+
value: string,
207+
policy?: MicrosoftGraphUrlPolicy,
208+
): string | null => {
209+
if (value === MICROSOFT_GRAPH_OPENAPI_URL) return value;
210+
return allowUnsafeUrl(value, policy) ?? null;
211+
};
212+
213+
const MICROSOFT_GRAPH_HOSTS = new Set([
214+
"graph.microsoft.com",
215+
"graph.microsoft.us",
216+
"dod-graph.microsoft.us",
217+
"microsoftgraph.chinacloudapi.cn",
218+
]);
219+
220+
const normalizeMicrosoftGraphBaseUrl = (
221+
value: string | undefined,
222+
policy?: MicrosoftGraphUrlPolicy,
223+
): string | undefined | null => {
224+
const unsafe = allowUnsafeUrl(value, policy);
225+
if (unsafe !== null) return unsafe;
226+
if (!value) return undefined;
227+
const parsed = parseTrustedHttpsUrl(value);
228+
if (!parsed || !MICROSOFT_GRAPH_HOSTS.has(parsed.hostname.toLowerCase())) return null;
229+
if (!/^\/(?:v1\.0|beta)(?:\/)?$/.test(parsed.pathname)) return null;
230+
if (parsed.search) return null;
231+
return parsed.toString().replace(/\/$/, "");
232+
};
233+
234+
const MICROSOFT_IDENTITY_HOSTS = new Set([
235+
"login.microsoftonline.com",
236+
"login.microsoftonline.us",
237+
"login.partner.microsoftonline.cn",
238+
]);
239+
240+
const normalizeMicrosoftOAuthEndpointUrl = (
241+
value: string,
242+
endpoint: "authorize" | "token",
243+
policy?: MicrosoftGraphUrlPolicy,
244+
): string | null => {
245+
const unsafe = allowUnsafeUrl(value, policy);
246+
if (unsafe !== null) return unsafe ?? null;
247+
const parsed = parseTrustedHttpsUrl(value);
248+
if (!parsed || !MICROSOFT_IDENTITY_HOSTS.has(parsed.hostname.toLowerCase())) return null;
249+
if (parsed.search) return null;
250+
const suffix = endpoint === "authorize" ? "authorize" : "token";
251+
return /^\/[^/]+\/oauth2\/v2\.0\/(?:authorize|token)$/.test(parsed.pathname) &&
252+
parsed.pathname.endsWith(`/${suffix}`)
253+
? parsed.toString()
254+
: null;
255+
};
256+
257+
const validateSelectionUrls = (
258+
selection: ReturnType<typeof normalizeSelection>,
259+
policy?: MicrosoftGraphUrlPolicy,
260+
): Effect.Effect<ReturnType<typeof normalizeSelection>, OpenApiParseError> =>
261+
Effect.gen(function* () {
262+
const specUrl = normalizeMicrosoftGraphSpecUrl(selection.specUrl, policy);
263+
if (!specUrl) {
264+
return yield* new OpenApiParseError({
265+
message: "Microsoft Graph specUrl must point to the trusted Microsoft Graph OpenAPI source",
266+
});
267+
}
268+
const baseUrl = normalizeMicrosoftGraphBaseUrl(selection.baseUrl, policy);
269+
if (baseUrl === null) {
270+
return yield* new OpenApiParseError({
271+
message: "Microsoft Graph baseUrl must point to a supported Microsoft Graph endpoint",
272+
});
273+
}
274+
const authorizationUrl = selection.authorizationUrl
275+
? normalizeMicrosoftOAuthEndpointUrl(selection.authorizationUrl, "authorize", policy)
276+
: undefined;
277+
if (selection.authorizationUrl && !authorizationUrl) {
278+
return yield* new OpenApiParseError({
279+
message: "Microsoft authorizationUrl must point to a supported Microsoft identity endpoint",
280+
});
281+
}
282+
const tokenUrl = selection.tokenUrl
283+
? normalizeMicrosoftOAuthEndpointUrl(selection.tokenUrl, "token", policy)
284+
: undefined;
285+
if (selection.tokenUrl && !tokenUrl) {
286+
return yield* new OpenApiParseError({
287+
message: "Microsoft tokenUrl must point to a supported Microsoft identity endpoint",
288+
});
289+
}
290+
const clientCredentialsTokenUrl = selection.clientCredentialsTokenUrl
291+
? normalizeMicrosoftOAuthEndpointUrl(selection.clientCredentialsTokenUrl, "token", policy)
292+
: undefined;
293+
if (selection.clientCredentialsTokenUrl && !clientCredentialsTokenUrl) {
294+
return yield* new OpenApiParseError({
295+
message:
296+
"Microsoft clientCredentialsTokenUrl must point to a supported Microsoft identity endpoint",
297+
});
298+
}
299+
return {
300+
...selection,
301+
specUrl,
302+
...(baseUrl ? { baseUrl } : { baseUrl: undefined }),
303+
...(authorizationUrl ? { authorizationUrl } : { authorizationUrl: undefined }),
304+
...(tokenUrl ? { tokenUrl } : { tokenUrl: undefined }),
305+
...(clientCredentialsTokenUrl
306+
? { clientCredentialsTokenUrl }
307+
: { clientCredentialsTokenUrl: undefined }),
308+
};
309+
});
310+
311+
const validateResolvedOAuthEndpoints = (
312+
endpoints: MicrosoftOAuthEndpoints,
313+
policy?: MicrosoftGraphUrlPolicy,
314+
): Effect.Effect<MicrosoftOAuthEndpoints, OpenApiParseError> =>
315+
Effect.gen(function* () {
316+
const authorizationUrl = normalizeMicrosoftOAuthEndpointUrl(
317+
endpoints.authorizationUrl,
318+
"authorize",
319+
policy,
320+
);
321+
const tokenUrl = normalizeMicrosoftOAuthEndpointUrl(endpoints.tokenUrl, "token", policy);
322+
const clientCredentialsTokenUrl = normalizeMicrosoftOAuthEndpointUrl(
323+
endpoints.clientCredentialsTokenUrl,
324+
"token",
325+
policy,
326+
);
327+
if (!authorizationUrl || !tokenUrl || !clientCredentialsTokenUrl) {
328+
return yield* new OpenApiParseError({
329+
message: "Microsoft OAuth endpoints must point to supported Microsoft identity endpoints",
330+
});
331+
}
332+
return { authorizationUrl, tokenUrl, clientCredentialsTokenUrl };
333+
});
334+
183335
const recordValues = (value: unknown): readonly unknown[] =>
184336
isRecord(value) ? Object.values(value) : [];
185337

@@ -490,9 +642,10 @@ const streamSelectedScopes = (
490642
export const buildMicrosoftGraphOpenApiSpec = (
491643
input: MicrosoftGraphSelectionInput,
492644
httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
645+
urlPolicy?: MicrosoftGraphUrlPolicy,
493646
): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> =>
494647
Effect.gen(function* () {
495-
const selection = normalizeSelection(input);
648+
const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy);
496649
const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(
497650
Effect.provide(httpClientLayer),
498651
);
@@ -512,7 +665,10 @@ export const buildMicrosoftGraphOpenApiSpec = (
512665
// Head + small components (servers + securitySchemes) parse cheaply and
513666
// carry everything `resolveOAuthEndpoints` needs.
514667
const headDoc = { ...parseHead(structure), components: parseSmallComponents(structure) };
515-
const endpoints = resolveOAuthEndpoints(headDoc, selection);
668+
const endpoints = yield* validateResolvedOAuthEndpoints(
669+
resolveOAuthEndpoints(headDoc, selection),
670+
urlPolicy,
671+
);
516672

517673
const permissionsReference =
518674
selection.coversFullGraph === true

packages/plugins/microsoft/src/sdk/plugin.test.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "@effect/vitest";
2-
import { Effect, Layer } from "effect";
2+
import { Effect, Exit, Layer } from "effect";
33
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
44

55
import {
@@ -10,7 +10,7 @@ import {
1010
} from "@executor-js/sdk";
1111
import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
1212

13-
import { microsoftPlugin } from "./plugin";
13+
import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin";
1414
import {
1515
MICROSOFT_AUTH_TEMPLATE_SLUG,
1616
MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
@@ -191,10 +191,54 @@ const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
191191
),
192192
);
193193

194-
const graphPlugins = () =>
195-
[microsoftPlugin({ httpClientLayer: graphHttpClientLayer }), memoryCredentialsPlugin()] as const;
194+
const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) =>
195+
[
196+
microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }),
197+
memoryCredentialsPlugin(),
198+
] as const;
196199

197200
describe("Microsoft Graph provider", () => {
201+
it.effect("rejects non-Microsoft URL overrides before fetching the Graph spec", () =>
202+
Effect.scoped(
203+
Effect.gen(function* () {
204+
let requests = 0;
205+
const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
206+
HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
207+
Effect.sync(() => {
208+
requests += 1;
209+
return HttpClientResponse.fromWeb(
210+
request,
211+
new Response("unexpected request", { status: 500 }),
212+
);
213+
}),
214+
),
215+
);
216+
const executor = yield* createExecutor(
217+
makeTestConfig({
218+
plugins: [
219+
microsoftPlugin({ httpClientLayer: blockedHttpClientLayer }),
220+
memoryCredentialsPlugin(),
221+
],
222+
}),
223+
);
224+
225+
const exit = yield* executor.microsoft
226+
.addGraph({
227+
slug: "bad_graph",
228+
baseUrl: "https://attacker.example/v1.0",
229+
specUrl: "https://attacker.example/openapi.yaml",
230+
authorizationUrl: "https://attacker.example/oauth2/v2.0/authorize",
231+
tokenUrl: "https://attacker.example/oauth2/v2.0/token",
232+
clientCredentialsTokenUrl: "https://attacker.example/oauth2/v2.0/token",
233+
})
234+
.pipe(Effect.exit);
235+
236+
expect(Exit.isFailure(exit)).toBe(true);
237+
expect(requests).toBe(0);
238+
}),
239+
),
240+
);
241+
198242
it.effect("adds a selected Graph workload source with one OAuth template", () =>
199243
Effect.scoped(
200244
Effect.gen(function* () {
@@ -414,7 +458,9 @@ describe("Microsoft Graph provider", () => {
414458
it.effect("adds Microsoft Graph from the emulator spec with app-only OAuth endpoints", () =>
415459
Effect.scoped(
416460
Effect.gen(function* () {
417-
const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
461+
const executor = yield* createExecutor(
462+
makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }),
463+
);
418464

419465
yield* executor.microsoft.addGraph({
420466
presetIds: ["users"],

packages/plugins/microsoft/src/sdk/plugin.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
buildMicrosoftGraphOpenApiSpec,
3838
decodeMicrosoftGraphIntegrationConfig,
3939
microsoftGraphKeepPathItem,
40+
type MicrosoftGraphUrlPolicy,
4041
type MicrosoftGraphIntegrationConfig,
4142
type MicrosoftGraphSpecBuild,
4243
} from "./graph";
@@ -83,6 +84,7 @@ export interface MicrosoftUpdateResult {
8384

8485
export interface MicrosoftPluginOptions {
8586
readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
87+
readonly allowUnsafeUrlOverrides?: boolean;
8688
}
8789

8890
const DEFAULT_MICROSOFT_SLUG = "microsoft_graph";
@@ -124,6 +126,7 @@ const describeMicrosoftIntegrationDisplay = (
124126
const makeMicrosoftPluginExtension = (
125127
ctx: PluginCtx<OpenapiStore>,
126128
httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
129+
urlPolicy?: MicrosoftGraphUrlPolicy,
127130
) => {
128131
const persistGraphOperations = (
129132
graph: MicrosoftGraphSpecBuild,
@@ -145,7 +148,7 @@ const makeMicrosoftPluginExtension = (
145148

146149
const addGraph = (config: MicrosoftGraphConfig) =>
147150
Effect.gen(function* () {
148-
const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer);
151+
const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer, urlPolicy);
149152
const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_MICROSOFT_SLUG);
150153

151154
const existing = yield* ctx.core.integrations.get(slug);
@@ -212,6 +215,7 @@ const makeMicrosoftPluginExtension = (
212215
input?.clientCredentialsTokenUrl ?? current.microsoftGraphClientCredentialsTokenUrl,
213216
},
214217
httpClientLayer,
218+
urlPolicy,
215219
);
216220
const previousOperations = yield* ctx.storage.listOperations(rawSlug);
217221
const previousNames = new Set(previousOperations.map((op) => op.toolName));
@@ -341,7 +345,9 @@ export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) =
341345
storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
342346

343347
extension: (ctx) =>
344-
makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer),
348+
makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, {
349+
allowUnsafeUrlOverrides: options?.allowUnsafeUrlOverrides === true,
350+
}),
345351

346352
describeAuthMethods: describeMicrosoftAuthMethods,
347353
describeIntegrationDisplay: describeMicrosoftIntegrationDisplay,

0 commit comments

Comments
 (0)