History

Pin Microsoft Graph provider URLs

Rhys Sullivan committed 4 months ago

Commit 0cf12bc1 parent f275c2c3 files · +217 −9Browse files

Unified · Split

3 files
  1. packages/plugins/microsoft/src/sdk/graph.ts +158 −2
  2. packages/plugins/microsoft/src/sdk/plugin.test.ts +51 −5
  3. packages/plugins/microsoft/src/sdk/plugin.ts +8 −2
packages/plugins/microsoft/src/sdk/graph.ts+158−2
@@ -61,6 +61,10 @@ export interface MicrosoftGraphSpecBuild { · 60 unmodified lines
61 readonly authenticationTemplate: readonly Authentication[];61 readonly authenticationTemplate: readonly Authentication[];
62}62}
63 63
64export interface MicrosoftGraphUrlPolicy {
65 readonly allowUnsafeUrlOverrides?: boolean;
66}
67
64export type MicrosoftGraphIntegrationConfig = OpenApiIntegrationConfig & {68export type MicrosoftGraphIntegrationConfig = OpenApiIntegrationConfig & {
65 readonly microsoftGraphPresetIds?: readonly string[];69 readonly microsoftGraphPresetIds?: readonly string[];
66 readonly microsoftGraphCustomScopes?: readonly string[];70 readonly microsoftGraphCustomScopes?: readonly string[];
@@ -180,6 +184,154 @@ const BASE_OAUTH_SCOPES = new Set(["offline_access", "openid", "profile", "email · 113 unmodified lines
180const firstString = (values: readonly unknown[]): string | undefined =>184const firstString = (values: readonly unknown[]): string | undefined =>
181 values.find((value): value is string => typeof value === "string" && value.trim().length > 0);185 values.find((value): value is string => typeof value === "string" && value.trim().length > 0);
182 186
187const 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
196const 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
205const 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
213const MICROSOFT_GRAPH_HOSTS = new Set([
214 "graph.microsoft.com",
215 "graph.microsoft.us",
216 "dod-graph.microsoft.us",
217 "microsoftgraph.chinacloudapi.cn",
218]);
219
220const 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
234const MICROSOFT_IDENTITY_HOSTS = new Set([
235 "login.microsoftonline.com",
236 "login.microsoftonline.us",
237 "login.partner.microsoftonline.cn",
238]);
239
240const 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
257const 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
311const 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
183const recordValues = (value: unknown): readonly unknown[] =>335const recordValues = (value: unknown): readonly unknown[] =>
184 isRecord(value) ? Object.values(value) : [];336 isRecord(value) ? Object.values(value) : [];
185 337
@@ -490,9 +642,10 @@ const streamSelectedScopes = ( · 304 unmodified lines
490export const buildMicrosoftGraphOpenApiSpec = (642export const buildMicrosoftGraphOpenApiSpec = (
491 input: MicrosoftGraphSelectionInput,643 input: MicrosoftGraphSelectionInput,
492 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,644 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
645 urlPolicy?: MicrosoftGraphUrlPolicy,
493): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> =>646): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> =>
494 Effect.gen(function* () {647 Effect.gen(function* () {
495 const selection = normalizeSelection(input);648 const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy);
496 const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(649 const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(
497 Effect.provide(httpClientLayer),650 Effect.provide(httpClientLayer),
498 );651 );
@@ -512,7 +665,10 @@ export const buildMicrosoftGraphOpenApiSpec = ( · 13 unmodified lines
512 // Head + small components (servers + securitySchemes) parse cheaply and665 // Head + small components (servers + securitySchemes) parse cheaply and
513 // carry everything `resolveOAuthEndpoints` needs.666 // carry everything `resolveOAuthEndpoints` needs.
514 const headDoc = { ...parseHead(structure), components: parseSmallComponents(structure) };667 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 );
516 672
517 const permissionsReference =673 const permissionsReference =
518 selection.coversFullGraph === true674 selection.coversFullGraph === true
packages/plugins/microsoft/src/sdk/plugin.test.ts+51−5
@@ -1,5 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";2import { Effect, Exit, Layer } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4 4
5import {5import {
@@ -10,7 +10,7 @@ import { · 4 unmodified lines
10} from "@executor-js/sdk";10} from "@executor-js/sdk";
11import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";11import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
12 12
13import { microsoftPlugin } from "./plugin";13import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin";
14import {14import {
15 MICROSOFT_AUTH_TEMPLATE_SLUG,15 MICROSOFT_AUTH_TEMPLATE_SLUG,
16 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,16 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
@@ -191,10 +191,54 @@ const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( · 174 unmodified lines
191 ),191 ),
192);192);
193 193
194const graphPlugins = () =>194const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) =>
195 [microsoftPlugin({ httpClientLayer: graphHttpClientLayer }), memoryCredentialsPlugin()] as const;195 [
196 microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }),
197 memoryCredentialsPlugin(),
198 ] as const;
196 199
197describe("Microsoft Graph provider", () => {200describe("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
198 it.effect("adds a selected Graph workload source with one OAuth template", () =>242 it.effect("adds a selected Graph workload source with one OAuth template", () =>
199 Effect.scoped(243 Effect.scoped(
200 Effect.gen(function* () {244 Effect.gen(function* () {
@@ -414,7 +458,9 @@ describe("Microsoft Graph provider", () => { · 213 unmodified lines
414 it.effect("adds Microsoft Graph from the emulator spec with app-only OAuth endpoints", () =>458 it.effect("adds Microsoft Graph from the emulator spec with app-only OAuth endpoints", () =>
415 Effect.scoped(459 Effect.scoped(
416 Effect.gen(function* () {460 Effect.gen(function* () {
417 const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));461 const executor = yield* createExecutor(
462 makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }),
463 );
418 464
419 yield* executor.microsoft.addGraph({465 yield* executor.microsoft.addGraph({
420 presetIds: ["users"],466 presetIds: ["users"],
packages/plugins/microsoft/src/sdk/plugin.ts+8−2
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
37 buildMicrosoftGraphOpenApiSpec,37 buildMicrosoftGraphOpenApiSpec,
38 decodeMicrosoftGraphIntegrationConfig,38 decodeMicrosoftGraphIntegrationConfig,
39 microsoftGraphKeepPathItem,39 microsoftGraphKeepPathItem,
40 type MicrosoftGraphUrlPolicy,
40 type MicrosoftGraphIntegrationConfig,41 type MicrosoftGraphIntegrationConfig,
41 type MicrosoftGraphSpecBuild,42 type MicrosoftGraphSpecBuild,
42} from "./graph";43} from "./graph";
@@ -83,6 +84,7 @@ export interface MicrosoftUpdateResult { · 40 unmodified lines
83 84
84export interface MicrosoftPluginOptions {85export interface MicrosoftPluginOptions {
85 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;86 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
87 readonly allowUnsafeUrlOverrides?: boolean;
86}88}
87 89
88const DEFAULT_MICROSOFT_SLUG = "microsoft_graph";90const DEFAULT_MICROSOFT_SLUG = "microsoft_graph";
@@ -124,6 +126,7 @@ const describeMicrosoftIntegrationDisplay = ( · 35 unmodified lines
124const makeMicrosoftPluginExtension = (126const makeMicrosoftPluginExtension = (
125 ctx: PluginCtx<OpenapiStore>,127 ctx: PluginCtx<OpenapiStore>,
126 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,128 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
129 urlPolicy?: MicrosoftGraphUrlPolicy,
127) => {130) => {
128 const persistGraphOperations = (131 const persistGraphOperations = (
129 graph: MicrosoftGraphSpecBuild,132 graph: MicrosoftGraphSpecBuild,
@@ -145,7 +148,7 @@ const makeMicrosoftPluginExtension = ( · 15 unmodified lines
145 148
146 const addGraph = (config: MicrosoftGraphConfig) =>149 const addGraph = (config: MicrosoftGraphConfig) =>
147 Effect.gen(function* () {150 Effect.gen(function* () {
148 const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer);151 const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer, urlPolicy);
149 const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_MICROSOFT_SLUG);152 const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_MICROSOFT_SLUG);
150 153
151 const existing = yield* ctx.core.integrations.get(slug);154 const existing = yield* ctx.core.integrations.get(slug);
@@ -212,6 +215,7 @@ const makeMicrosoftPluginExtension = ( · 60 unmodified lines
212 input?.clientCredentialsTokenUrl ?? current.microsoftGraphClientCredentialsTokenUrl,215 input?.clientCredentialsTokenUrl ?? current.microsoftGraphClientCredentialsTokenUrl,
213 },216 },
214 httpClientLayer,217 httpClientLayer,
218 urlPolicy,
215 );219 );
216 const previousOperations = yield* ctx.storage.listOperations(rawSlug);220 const previousOperations = yield* ctx.storage.listOperations(rawSlug);
217 const previousNames = new Set(previousOperations.map((op) => op.toolName));221 const previousNames = new Set(previousOperations.map((op) => op.toolName));
@@ -341,7 +345,9 @@ export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) = · 123 unmodified lines
341 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),345 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
342 346
343 extension: (ctx) =>347 extension: (ctx) =>
344 makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer),348 makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, {
349 allowUnsafeUrlOverrides: options?.allowUnsafeUrlOverrides === true,
350 }),
345 351
346 describeAuthMethods: describeMicrosoftAuthMethods,352 describeAuthMethods: describeMicrosoftAuthMethods,
347 describeIntegrationDisplay: describeMicrosoftIntegrationDisplay,353 describeIntegrationDisplay: describeMicrosoftIntegrationDisplay,