Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/plugins/google/src/sdk/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
70 changes: 54 additions & 16 deletions packages/plugins/google/src/sdk/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

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);
}
Expand Down
39 changes: 38 additions & 1 deletion packages/plugins/google/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
() =>
Expand Down
13 changes: 7 additions & 6 deletions packages/plugins/google/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
import {
convertGoogleDiscoveryBundleToOpenApi,
fetchGoogleDiscoveryDocument,
isGoogleDiscoveryUrl,
normalizeGoogleDiscoveryUrl,
} from "./discovery";
import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
import { googleOpenApiBundlePreset } from "./presets";
Expand Down Expand Up @@ -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) ?? [])),
];
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
Expand Down Expand Up @@ -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)),
Expand All @@ -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,
});
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/google/src/sdk/presets.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeGoogleDiscoveryUrl } from "./discovery";

export interface GooglePreset {
readonly id: string;
readonly name: string;
Expand Down Expand Up @@ -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);
Expand Down
Loading