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
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 · 113 unmodified lines
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 = ( · 304 unmodified lines
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 = ( · 13 unmodified lines
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+51−5
@@ -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 { · 4 unmodified lines
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)( · 174 unmodified lines
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", () => { · 213 unmodified lines
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+8−2
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
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 { · 40 unmodified lines
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 = ( · 35 unmodified lines
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 = ( · 15 unmodified lines
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 = ( · 60 unmodified lines
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) = · 123 unmodified lines
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,