History

Restrict Google Discovery bundle URLs

Rhys Sullivan committed 4 months ago

Commit f275c2c1 parent 5865d0c5 files · +130 −23Browse files

Unified · Split

5 files
  1. packages/plugins/google/src/sdk/discovery.test.ts +27 −0
  2. packages/plugins/google/src/sdk/discovery.ts +54 −16
  3. packages/plugins/google/src/sdk/plugin.test.ts +38 −1
  4. packages/plugins/google/src/sdk/plugin.ts +7 −6
  5. packages/plugins/google/src/sdk/presets.ts +4 −0
packages/plugins/google/src/sdk/discovery.test.ts+27−0
@@ -5,6 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; · 4 unmodified lines
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({ · 33 unmodified lines
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+54−16
@@ -231,38 +231,76 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); · 230 unmodified lines
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+38−1
@@ -12,7 +12,7 @@ · 11 unmodified lines
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 = () => · 155 unmodified lines
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+7−6
@@ -33,7 +33,7 @@ import { · 32 unmodified lines
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 = ( · 43 unmodified lines
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) => ({ · 236 unmodified lines
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) => ({ · 2 unmodified lines
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+4−0
@@ -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 · 238 unmodified lines
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);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t