Skip to content

Commit 94c590f

Browse files
committed
Restrict Google Discovery bundle URLs
1 parent e747baa commit 94c590f

5 files changed

Lines changed: 130 additions & 23 deletions

File tree

packages/plugins/google/src/sdk/discovery.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
55
import {
66
convertGoogleDiscoveryBundleToOpenApi,
77
convertGoogleDiscoveryToOpenApi,
8+
isGoogleDiscoveryUrl,
9+
normalizeGoogleDiscoveryUrl,
810
} from "./discovery";
911
import { extract, parse } from "@executor-js/plugin-openapi";
1012

@@ -44,6 +46,31 @@ const ConvertedSpec = Schema.Struct({
4446

4547
const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec));
4648

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+
4774
const normalizeOpenApiRefsForPreview = (node: unknown): unknown => {
4875
if (node == null || typeof node !== "object") return node;
4976
if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview);

packages/plugins/google/src/sdk/discovery.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -231,38 +231,76 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod);
231231
const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);
232232
const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
233233

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;
243240
const rawService = host.slice(0, -".googleapis.com".length);
241+
if (!rawService || rawService.includes(".")) return null;
244242
const service =
245243
rawService === "calendar-json"
246244
? "calendar"
247245
: rawService.endsWith("-json")
248246
? rawService.slice(0, -5)
249247
: rawService;
250-
return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed;
248+
return /^[a-z0-9][a-z0-9-]*$/.test(service) ? service : null;
251249
};
252250

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;
256254
const parsed = new URL(trimmed);
255+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) {
256+
return null;
257+
}
258+
257259
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;
260291
};
261292

262293
export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(
263294
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+
}
264302
const client = yield* HttpClient.HttpClient;
265-
const requestUrl = new URL(discoveryUrl);
303+
const requestUrl = new URL(normalizedDiscoveryUrl);
266304
for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
267305
requestUrl.searchParams.set(name, value);
268306
}

packages/plugins/google/src/sdk/plugin.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// ---------------------------------------------------------------------------
1313

1414
import { describe, expect, it } from "@effect/vitest";
15-
import { Effect, Layer } from "effect";
15+
import { Effect, Exit, Layer } from "effect";
1616
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
1717

1818
import {
@@ -174,6 +174,43 @@ const bundlePlugins = () =>
174174
[googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const;
175175

176176
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+
177214
it.effect(
178215
"addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions",
179216
() =>

packages/plugins/google/src/sdk/plugin.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
import {
3434
convertGoogleDiscoveryBundleToOpenApi,
3535
fetchGoogleDiscoveryDocument,
36-
isGoogleDiscoveryUrl,
36+
normalizeGoogleDiscoveryUrl,
3737
} from "./discovery";
3838
import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
3939
import { googleOpenApiBundlePreset } from "./presets";
@@ -83,7 +83,7 @@ const fetchGoogleBundleConversion = (
8383
).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents })));
8484

8585
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) ?? [])),
8787
];
8888

8989
const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
@@ -326,13 +326,14 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({
326326
detect: ({ ctx, url }) =>
327327
Effect.gen(function* () {
328328
const trimmed = url.trim();
329-
if (!trimmed || !isGoogleDiscoveryUrl(trimmed)) return null;
329+
const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed);
330+
if (!trimmed || !discoveryUrl) return null;
330331
const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
331-
const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe(
332+
const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe(
332333
Effect.provide(httpClientLayer),
333334
Effect.flatMap((documentText) =>
334335
convertGoogleDiscoveryBundleToOpenApi({
335-
documents: [{ discoveryUrl: trimmed, documentText }],
336+
documents: [{ discoveryUrl, documentText }],
336337
}),
337338
),
338339
Effect.catch(() => Effect.succeed(null)),
@@ -341,7 +342,7 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({
341342
return IntegrationDetectionResult.make({
342343
kind: "google",
343344
confidence: "high",
344-
endpoint: trimmed,
345+
endpoint: discoveryUrl,
345346
name: conversion.title,
346347
slug: DEFAULT_GOOGLE_SLUG,
347348
});

packages/plugins/google/src/sdk/presets.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { normalizeGoogleDiscoveryUrl } from "./discovery";
2+
13
export interface GooglePreset {
24
readonly id: string;
35
readonly name: string;
@@ -242,6 +244,8 @@ export const googleOAuthConsentScopesForPreset = (presetId: string): readonly st
242244
// ---------------------------------------------------------------------------
243245

244246
const normalizeGooglePresetUrl = (url: string): string => {
247+
const discoveryUrl = normalizeGoogleDiscoveryUrl(url);
248+
if (discoveryUrl) return discoveryUrl;
245249
const trimmed = url.trim();
246250
if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, "");
247251
const parsed = new URL(trimmed);

0 commit comments

Comments
 (0)