diff --git a/packages/plugins/google/src/sdk/discovery.test.ts b/packages/plugins/google/src/sdk/discovery.test.ts index 233787cf9..f5608cec4 100644 --- a/packages/plugins/google/src/sdk/discovery.test.ts +++ b/packages/plugins/google/src/sdk/discovery.test.ts @@ -5,6 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; import { convertGoogleDiscoveryBundleToOpenApi, convertGoogleDiscoveryToOpenApi, + isGoogleDiscoveryUrl, + normalizeGoogleDiscoveryUrl, } from "./discovery"; import { extract, parse } from "@executor-js/plugin-openapi"; @@ -44,6 +46,31 @@ const ConvertedSpec = Schema.Struct({ const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); +it("accepts only supported HTTPS Google Discovery endpoints", () => { + expect( + normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"), + ).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"); + expect( + normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"), + ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest"); + + expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( + true, + ); + expect(isGoogleDiscoveryUrl("https://evilgoogleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( + false, + ); + expect(isGoogleDiscoveryUrl("http://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( + false, + ); + expect( + isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest?next=x"), + ).toBe(false); + expect( + isGoogleDiscoveryUrl("https://token@www.googleapis.com/discovery/v1/apis/gmail/v1/rest"), + ).toBe(false); +}); + const normalizeOpenApiRefsForPreview = (node: unknown): unknown => { if (node == null || typeof node !== "object") return node; if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview); diff --git a/packages/plugins/google/src/sdk/discovery.ts b/packages/plugins/google/src/sdk/discovery.ts index bd786fef2..4bc485af7 100644 --- a/packages/plugins/google/src/sdk/discovery.ts +++ b/packages/plugins/google/src/sdk/discovery.ts @@ -231,38 +231,76 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); -const normalizeDiscoveryUrl = (discoveryUrl: string): string => { - const trimmed = discoveryUrl.trim(); - if (!URL.canParse(trimmed)) return trimmed; - const parsed = new URL(trimmed); - if (parsed.pathname !== "/$discovery/rest") return trimmed; - const version = parsed.searchParams.get("version")?.trim(); - if (!version) return trimmed; - const host = parsed.hostname.toLowerCase(); - if (!host.endsWith(".googleapis.com")) return trimmed; +const DISCOVERY_SERVICE_PATH_RE = + /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/; +const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/; + +const serviceFromGoogleApisHost = (host: string): string | null => { + if (!host.endsWith(".googleapis.com")) return null; const rawService = host.slice(0, -".googleapis.com".length); + if (!rawService || rawService.includes(".")) return null; const service = rawService === "calendar-json" ? "calendar" : rawService.endsWith("-json") ? rawService.slice(0, -5) : rawService; - return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed; + return /^[a-z0-9][a-z0-9-]*$/.test(service) ? service : null; }; -export const isGoogleDiscoveryUrl = (url: string): boolean => { - const trimmed = url.trim(); - if (!URL.canParse(trimmed)) return false; +export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null => { + const trimmed = discoveryUrl.trim(); + if (!URL.canParse(trimmed)) return null; const parsed = new URL(trimmed); + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) { + return null; + } + const host = parsed.hostname.toLowerCase(); - if (!host.endsWith("googleapis.com")) return false; - return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); + if (host === "www.googleapis.com") { + if (parsed.search) return null; + const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE); + const service = match?.[1]; + const version = match?.[2]; + return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null; + } + + const service = serviceFromGoogleApisHost(host); + if (!service || !["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) { + return null; + } + const keys = [...parsed.searchParams.keys()]; + const version = parsed.searchParams.get("version")?.trim(); + if ( + keys.length !== 1 || + keys[0] !== "version" || + !version || + !DISCOVERY_VERSION_RE.test(version) + ) { + return null; + } + return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; +}; + +const normalizeDiscoveryUrl = (discoveryUrl: string): string => { + return normalizeGoogleDiscoveryUrl(discoveryUrl) ?? discoveryUrl.trim(); +}; + +export const isGoogleDiscoveryUrl = (url: string): boolean => { + return normalizeGoogleDiscoveryUrl(url) !== null; }; export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { + const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl); + if (!normalizedDiscoveryUrl) { + return yield* new OpenApiParseError({ + message: + "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", + }); + } const client = yield* HttpClient.HttpClient; - const requestUrl = new URL(discoveryUrl); + const requestUrl = new URL(normalizedDiscoveryUrl); for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { requestUrl.searchParams.set(name, value); } diff --git a/packages/plugins/google/src/sdk/plugin.test.ts b/packages/plugins/google/src/sdk/plugin.test.ts index e4266cc78..a0df2b661 100644 --- a/packages/plugins/google/src/sdk/plugin.test.ts +++ b/packages/plugins/google/src/sdk/plugin.test.ts @@ -12,7 +12,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { @@ -174,6 +174,43 @@ const bundlePlugins = () => [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; describe("Google bundle add flow", () => { + it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () => + Effect.scoped( + Effect.gen(function* () { + let requests = 0; + const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + requests += 1; + return HttpClientResponse.fromWeb( + request, + new Response("unexpected request", { status: 500 }), + ); + }), + ), + ); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + googlePlugin({ httpClientLayer: blockedHttpClientLayer }), + memoryCredentialsPlugin(), + ], + }), + ); + + const exit = yield* executor.google + .addBundle({ + urls: ["https://evilgoogleapis.com/discovery/v1/apis/calendar/v3/rest"], + slug: "bad_google", + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(requests).toBe(0); + }), + ), + ); + it.effect( "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions", () => diff --git a/packages/plugins/google/src/sdk/plugin.ts b/packages/plugins/google/src/sdk/plugin.ts index fdeb93422..597a30b96 100644 --- a/packages/plugins/google/src/sdk/plugin.ts +++ b/packages/plugins/google/src/sdk/plugin.ts @@ -33,7 +33,7 @@ import { import { convertGoogleDiscoveryBundleToOpenApi, fetchGoogleDiscoveryDocument, - isGoogleDiscoveryUrl, + normalizeGoogleDiscoveryUrl, } from "./discovery"; import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; import { googleOpenApiBundlePreset } from "./presets"; @@ -83,7 +83,7 @@ const fetchGoogleBundleConversion = ( ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents }))); const uniqueUrls = (urls: readonly string[]): readonly string[] => [ - ...new Set(urls.map((url) => url.trim()).filter((url) => url.length > 0)), + ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])), ]; const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { @@ -326,13 +326,14 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ detect: ({ ctx, url }) => Effect.gen(function* () { const trimmed = url.trim(); - if (!trimmed || !isGoogleDiscoveryUrl(trimmed)) return null; + const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed); + if (!trimmed || !discoveryUrl) return null; const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; - const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe( + const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe( Effect.provide(httpClientLayer), Effect.flatMap((documentText) => convertGoogleDiscoveryBundleToOpenApi({ - documents: [{ discoveryUrl: trimmed, documentText }], + documents: [{ discoveryUrl, documentText }], }), ), Effect.catch(() => Effect.succeed(null)), @@ -341,7 +342,7 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ return IntegrationDetectionResult.make({ kind: "google", confidence: "high", - endpoint: trimmed, + endpoint: discoveryUrl, name: conversion.title, slug: DEFAULT_GOOGLE_SLUG, }); diff --git a/packages/plugins/google/src/sdk/presets.ts b/packages/plugins/google/src/sdk/presets.ts index 992dfc327..e369c369d 100644 --- a/packages/plugins/google/src/sdk/presets.ts +++ b/packages/plugins/google/src/sdk/presets.ts @@ -1,3 +1,5 @@ +import { normalizeGoogleDiscoveryUrl } from "./discovery"; + export interface GooglePreset { readonly id: string; readonly name: string; @@ -242,6 +244,8 @@ export const googleOAuthConsentScopesForPreset = (presetId: string): readonly st // --------------------------------------------------------------------------- const normalizeGooglePresetUrl = (url: string): string => { + const discoveryUrl = normalizeGoogleDiscoveryUrl(url); + if (discoveryUrl) return discoveryUrl; const trimmed = url.trim(); if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); const parsed = new URL(trimmed);