Unified · Split
| @@ -5,6 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | convertGoogleDiscoveryBundleToOpenApi, |
| 7 | 7 | convertGoogleDiscoveryToOpenApi, |
| 8 | + isGoogleDiscoveryUrl, | |
| 9 | + normalizeGoogleDiscoveryUrl, | |
| 8 | 10 | } from "./discovery"; |
| 9 | 11 | import { extract, parse } from "@executor-js/plugin-openapi"; |
| 10 | 12 | |
| @@ -44,6 +46,31 @@ const ConvertedSpec = Schema.Struct({ · 33 unmodified lines | ||
| 44 | 46 | |
| 45 | 47 | const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); |
| 46 | 48 | |
| 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 | + | |
| 47 | 74 | const normalizeOpenApiRefsForPreview = (node: unknown): unknown => { |
| 48 | 75 | if (node == null || typeof node !== "object") return node; |
| 49 | 76 | if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview); |
| @@ -231,38 +231,76 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); · 230 unmodified lines | ||
| 231 | 231 | const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); |
| 232 | 232 | const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); |
| 233 | 233 | |
| 234 | −const normalizeDiscoveryUrl = (discoveryUrl: string): string => { | |
| 235 | − const trimmed = discoveryUrl.trim(); | |
| 236 | − if (!URL.canParse(trimmed)) return trimmed; | |
| 237 | − const parsed = new URL(trimmed); | |
| 238 | − if (parsed.pathname !== "/$discovery/rest") return trimmed; | |
| 239 | − const version = parsed.searchParams.get("version")?.trim(); | |
| 240 | − if (!version) return trimmed; | |
| 241 | − const host = parsed.hostname.toLowerCase(); | |
| 242 | − if (!host.endsWith(".googleapis.com")) return trimmed; | |
| 234 | +const DISCOVERY_SERVICE_PATH_RE = | |
| 235 | + /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/; | |
| 236 | +const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/; | |
| 237 | + | |
| 238 | +const serviceFromGoogleApisHost = (host: string): string | null => { | |
| 239 | + if (!host.endsWith(".googleapis.com")) return null; | |
| 243 | 240 | const rawService = host.slice(0, -".googleapis.com".length); |
| 241 | + if (!rawService || rawService.includes(".")) return null; | |
| 244 | 242 | const service = |
| 245 | 243 | rawService === "calendar-json" |
| 246 | 244 | ? "calendar" |
| 247 | 245 | : rawService.endsWith("-json") |
| 248 | 246 | ? rawService.slice(0, -5) |
| 249 | 247 | : rawService; |
| 250 | − return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed; | |
| 248 | + return /^[a-z0-9][a-z0-9-]*$/.test(service) ? service : null; | |
| 251 | 249 | }; |
| 252 | 250 | |
| 253 | −export const isGoogleDiscoveryUrl = (url: string): boolean => { | |
| 254 | − const trimmed = url.trim(); | |
| 255 | − if (!URL.canParse(trimmed)) return false; | |
| 251 | +export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null => { | |
| 252 | + const trimmed = discoveryUrl.trim(); | |
| 253 | + if (!URL.canParse(trimmed)) return null; | |
| 256 | 254 | const parsed = new URL(trimmed); |
| 255 | + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) { | |
| 256 | + return null; | |
| 257 | + } | |
| 258 | + | |
| 257 | 259 | const host = parsed.hostname.toLowerCase(); |
| 258 | − if (!host.endsWith("googleapis.com")) return false; | |
| 259 | − return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); | |
| 260 | + if (host === "www.googleapis.com") { | |
| 261 | + if (parsed.search) return null; | |
| 262 | + const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE); | |
| 263 | + const service = match?.[1]; | |
| 264 | + const version = match?.[2]; | |
| 265 | + return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null; | |
| 266 | + } | |
| 267 | + | |
| 268 | + const service = serviceFromGoogleApisHost(host); | |
| 269 | + if (!service || !["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) { | |
| 270 | + return null; | |
| 271 | + } | |
| 272 | + const keys = [...parsed.searchParams.keys()]; | |
| 273 | + const version = parsed.searchParams.get("version")?.trim(); | |
| 274 | + if ( | |
| 275 | + keys.length !== 1 || | |
| 276 | + keys[0] !== "version" || | |
| 277 | + !version || | |
| 278 | + !DISCOVERY_VERSION_RE.test(version) | |
| 279 | + ) { | |
| 280 | + return null; | |
| 281 | + } | |
| 282 | + return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; | |
| 283 | +}; | |
| 284 | + | |
| 285 | +const normalizeDiscoveryUrl = (discoveryUrl: string): string => { | |
| 286 | + return normalizeGoogleDiscoveryUrl(discoveryUrl) ?? discoveryUrl.trim(); | |
| 287 | +}; | |
| 288 | + | |
| 289 | +export const isGoogleDiscoveryUrl = (url: string): boolean => { | |
| 290 | + return normalizeGoogleDiscoveryUrl(url) !== null; | |
| 260 | 291 | }; |
| 261 | 292 | |
| 262 | 293 | export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( |
| 263 | 294 | function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { |
| 295 | + const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl); | |
| 296 | + if (!normalizedDiscoveryUrl) { | |
| 297 | + return yield* new OpenApiParseError({ | |
| 298 | + message: | |
| 299 | + "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", | |
| 300 | + }); | |
| 301 | + } | |
| 264 | 302 | const client = yield* HttpClient.HttpClient; |
| 265 | − const requestUrl = new URL(discoveryUrl); | |
| 303 | + const requestUrl = new URL(normalizedDiscoveryUrl); | |
| 266 | 304 | for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { |
| 267 | 305 | requestUrl.searchParams.set(name, value); |
| 268 | 306 | } |
| @@ -12,7 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | |
| 14 | 14 | import { describe, expect, it } from "@effect/vitest"; |
| 15 | −import { Effect, Layer } from "effect"; | |
| 15 | +import { Effect, Exit, Layer } from "effect"; | |
| 16 | 16 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 17 | 17 | |
| 18 | 18 | import { |
| @@ -174,6 +174,43 @@ const bundlePlugins = () => · 155 unmodified lines | ||
| 174 | 174 | [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; |
| 175 | 175 | |
| 176 | 176 | describe("Google bundle add flow", () => { |
| 177 | + it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () => | |
| 178 | + Effect.scoped( | |
| 179 | + Effect.gen(function* () { | |
| 180 | + let requests = 0; | |
| 181 | + const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | |
| 182 | + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | |
| 183 | + Effect.sync(() => { | |
| 184 | + requests += 1; | |
| 185 | + return HttpClientResponse.fromWeb( | |
| 186 | + request, | |
| 187 | + new Response("unexpected request", { status: 500 }), | |
| 188 | + ); | |
| 189 | + }), | |
| 190 | + ), | |
| 191 | + ); | |
| 192 | + const executor = yield* createExecutor( | |
| 193 | + makeTestConfig({ | |
| 194 | + plugins: [ | |
| 195 | + googlePlugin({ httpClientLayer: blockedHttpClientLayer }), | |
| 196 | + memoryCredentialsPlugin(), | |
| 197 | + ], | |
| 198 | + }), | |
| 199 | + ); | |
| 200 | + | |
| 201 | + const exit = yield* executor.google | |
| 202 | + .addBundle({ | |
| 203 | + urls: ["https://evilgoogleapis.com/discovery/v1/apis/calendar/v3/rest"], | |
| 204 | + slug: "bad_google", | |
| 205 | + }) | |
| 206 | + .pipe(Effect.exit); | |
| 207 | + | |
| 208 | + expect(Exit.isFailure(exit)).toBe(true); | |
| 209 | + expect(requests).toBe(0); | |
| 210 | + }), | |
| 211 | + ), | |
| 212 | + ); | |
| 213 | + | |
| 177 | 214 | it.effect( |
| 178 | 215 | "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions", |
| 179 | 216 | () => |
| @@ -33,7 +33,7 @@ import { · 32 unmodified lines | ||
| 33 | 33 | import { |
| 34 | 34 | convertGoogleDiscoveryBundleToOpenApi, |
| 35 | 35 | fetchGoogleDiscoveryDocument, |
| 36 | − isGoogleDiscoveryUrl, | |
| 36 | + normalizeGoogleDiscoveryUrl, | |
| 37 | 37 | } from "./discovery"; |
| 38 | 38 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; |
| 39 | 39 | import { googleOpenApiBundlePreset } from "./presets"; |
| @@ -83,7 +83,7 @@ const fetchGoogleBundleConversion = ( · 43 unmodified lines | ||
| 83 | 83 | ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents }))); |
| 84 | 84 | |
| 85 | 85 | const uniqueUrls = (urls: readonly string[]): readonly string[] => [ |
| 86 | − ...new Set(urls.map((url) => url.trim()).filter((url) => url.length > 0)), | |
| 86 | + ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])), | |
| 87 | 87 | ]; |
| 88 | 88 | |
| 89 | 89 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { |
| @@ -326,13 +326,14 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ · 236 unmodified lines | ||
| 326 | 326 | detect: ({ ctx, url }) => |
| 327 | 327 | Effect.gen(function* () { |
| 328 | 328 | const trimmed = url.trim(); |
| 329 | − if (!trimmed || !isGoogleDiscoveryUrl(trimmed)) return null; | |
| 329 | + const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed); | |
| 330 | + if (!trimmed || !discoveryUrl) return null; | |
| 330 | 331 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; |
| 331 | − const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe( | |
| 332 | + const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe( | |
| 332 | 333 | Effect.provide(httpClientLayer), |
| 333 | 334 | Effect.flatMap((documentText) => |
| 334 | 335 | convertGoogleDiscoveryBundleToOpenApi({ |
| 335 | − documents: [{ discoveryUrl: trimmed, documentText }], | |
| 336 | + documents: [{ discoveryUrl, documentText }], | |
| 336 | 337 | }), |
| 337 | 338 | ), |
| 338 | 339 | Effect.catch(() => Effect.succeed(null)), |
| @@ -341,7 +342,7 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ · 2 unmodified lines | ||
| 341 | 342 | return IntegrationDetectionResult.make({ |
| 342 | 343 | kind: "google", |
| 343 | 344 | confidence: "high", |
| 344 | − endpoint: trimmed, | |
| 345 | + endpoint: discoveryUrl, | |
| 345 | 346 | name: conversion.title, |
| 346 | 347 | slug: DEFAULT_GOOGLE_SLUG, |
| 347 | 348 | }); |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import { normalizeGoogleDiscoveryUrl } from "./discovery"; | |
| 2 | + | |
| 1 | 3 | export interface GooglePreset { |
| 2 | 4 | readonly id: string; |
| 3 | 5 | readonly name: string; |
| @@ -242,6 +244,8 @@ export const googleOAuthConsentScopesForPreset = (presetId: string): readonly st · 238 unmodified lines | ||
| 242 | 244 | // --------------------------------------------------------------------------- |
| 243 | 245 | |
| 244 | 246 | const normalizeGooglePresetUrl = (url: string): string => { |
| 247 | + const discoveryUrl = normalizeGoogleDiscoveryUrl(url); | |
| 248 | + if (discoveryUrl) return discoveryUrl; | |
| 245 | 249 | const trimmed = url.trim(); |
| 246 | 250 | if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); |
| 247 | 251 | const parsed = new URL(trimmed); |