| @@ -61,6 +61,10 @@ export interface MicrosoftGraphSpecBuild { · 60 unmodified lines |
| 61 | 61 | readonly authenticationTemplate: readonly Authentication[]; |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | +export interface MicrosoftGraphUrlPolicy { |
| 65 | + readonly allowUnsafeUrlOverrides?: boolean; |
| 66 | +} |
| 67 | + |
| 64 | 68 | export type MicrosoftGraphIntegrationConfig = OpenApiIntegrationConfig & { |
| 65 | 69 | readonly microsoftGraphPresetIds?: readonly string[]; |
| 66 | 70 | readonly microsoftGraphCustomScopes?: readonly string[]; |
| @@ -180,6 +184,154 @@ const BASE_OAUTH_SCOPES = new Set(["offline_access", "openid", "profile", "email · 113 unmodified lines |
| 180 | 184 | const firstString = (values: readonly unknown[]): string | undefined => |
| 181 | 185 | values.find((value): value is string => typeof value === "string" && value.trim().length > 0); |
| 182 | 186 | |
| 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 | + |
| 183 | 335 | const recordValues = (value: unknown): readonly unknown[] => |
| 184 | 336 | isRecord(value) ? Object.values(value) : []; |
| 185 | 337 | |
| @@ -490,9 +642,10 @@ const streamSelectedScopes = ( · 304 unmodified lines |
| 490 | 642 | export const buildMicrosoftGraphOpenApiSpec = ( |
| 491 | 643 | input: MicrosoftGraphSelectionInput, |
| 492 | 644 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| 645 | + urlPolicy?: MicrosoftGraphUrlPolicy, |
| 493 | 646 | ): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> => |
| 494 | 647 | Effect.gen(function* () { |
| 495 | | − const selection = normalizeSelection(input); |
| 648 | + const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy); |
| 496 | 649 | const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( |
| 497 | 650 | Effect.provide(httpClientLayer), |
| 498 | 651 | ); |
| @@ -512,7 +665,10 @@ export const buildMicrosoftGraphOpenApiSpec = ( · 13 unmodified lines |
| 512 | 665 | // Head + small components (servers + securitySchemes) parse cheaply and |
| 513 | 666 | // carry everything `resolveOAuthEndpoints` needs. |
| 514 | 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 | 673 | const permissionsReference = |
| 518 | 674 | selection.coversFullGraph === true |