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
5import {5import {
6 convertGoogleDiscoveryBundleToOpenApi,6 convertGoogleDiscoveryBundleToOpenApi,
7 convertGoogleDiscoveryToOpenApi,7 convertGoogleDiscoveryToOpenApi,
8 isGoogleDiscoveryUrl,
9 normalizeGoogleDiscoveryUrl,
8} from "./discovery";10} from "./discovery";
9import { extract, parse } from "@executor-js/plugin-openapi";11import { extract, parse } from "@executor-js/plugin-openapi";
10 12
@@ -44,6 +46,31 @@ const ConvertedSpec = Schema.Struct({ · 33 unmodified lines
44 46
45const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec));47const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec));
46 48
49it("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
47const normalizeOpenApiRefsForPreview = (node: unknown): unknown => {74const normalizeOpenApiRefsForPreview = (node: unknown): unknown => {
48 if (node == null || typeof node !== "object") return node;75 if (node == null || typeof node !== "object") return node;
49 if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview);76 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
231const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);231const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);
232const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));232const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
233 233
234const normalizeDiscoveryUrl = (discoveryUrl: string): string => {234const DISCOVERY_SERVICE_PATH_RE =
235 const trimmed = discoveryUrl.trim();235 /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/;
236 if (!URL.canParse(trimmed)) return trimmed;236const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/;
237 const parsed = new URL(trimmed);237
238 if (parsed.pathname !== "/$discovery/rest") return trimmed;238const serviceFromGoogleApisHost = (host: string): string | null => {
239 const version = parsed.searchParams.get("version")?.trim();239 if (!host.endsWith(".googleapis.com")) return null;
240 if (!version) return trimmed;
241 const host = parsed.hostname.toLowerCase();
242 if (!host.endsWith(".googleapis.com")) return trimmed;
243 const rawService = host.slice(0, -".googleapis.com".length);240 const rawService = host.slice(0, -".googleapis.com".length);
241 if (!rawService || rawService.includes(".")) return null;
244 const service =242 const service =
245 rawService === "calendar-json"243 rawService === "calendar-json"
246 ? "calendar"244 ? "calendar"
247 : rawService.endsWith("-json")245 : rawService.endsWith("-json")
248 ? rawService.slice(0, -5)246 ? rawService.slice(0, -5)
249 : rawService;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
253export const isGoogleDiscoveryUrl = (url: string): boolean => {251export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null => {
254 const trimmed = url.trim();252 const trimmed = discoveryUrl.trim();
255 if (!URL.canParse(trimmed)) return false;253 if (!URL.canParse(trimmed)) return null;
256 const parsed = new URL(trimmed);254 const parsed = new URL(trimmed);
255 if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) {
256 return null;
257 }
258
257 const host = parsed.hostname.toLowerCase();259 const host = parsed.hostname.toLowerCase();
258 if (!host.endsWith("googleapis.com")) return false;260 if (host === "www.googleapis.com") {
259 return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");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
285const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
286 return normalizeGoogleDiscoveryUrl(discoveryUrl) ?? discoveryUrl.trim();
287};
288
289export const isGoogleDiscoveryUrl = (url: string): boolean => {
290 return normalizeGoogleDiscoveryUrl(url) !== null;
260};291};
261 292
262export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(293export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(
263 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {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 const client = yield* HttpClient.HttpClient;302 const client = yield* HttpClient.HttpClient;
265 const requestUrl = new URL(discoveryUrl);303 const requestUrl = new URL(normalizedDiscoveryUrl);
266 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {304 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
267 requestUrl.searchParams.set(name, value);305 requestUrl.searchParams.set(name, value);
268 }306 }
packages/plugins/google/src/sdk/plugin.test.ts+38−1
@@ -12,7 +12,7 @@ · 11 unmodified lines
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13 13
14import { describe, expect, it } from "@effect/vitest";14import { describe, expect, it } from "@effect/vitest";
15import { Effect, Layer } from "effect";15import { Effect, Exit, Layer } from "effect";
16import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";16import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
17 17
18import {18import {
@@ -174,6 +174,43 @@ const bundlePlugins = () => · 155 unmodified lines
174 [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const;174 [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const;
175 175
176describe("Google bundle add flow", () => {176describe("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 it.effect(214 it.effect(
178 "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions",215 "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions",
179 () =>216 () =>
packages/plugins/google/src/sdk/plugin.ts+7−6
@@ -33,7 +33,7 @@ import { · 32 unmodified lines
33import {33import {
34 convertGoogleDiscoveryBundleToOpenApi,34 convertGoogleDiscoveryBundleToOpenApi,
35 fetchGoogleDiscoveryDocument,35 fetchGoogleDiscoveryDocument,
36 isGoogleDiscoveryUrl,36 normalizeGoogleDiscoveryUrl,
37} from "./discovery";37} from "./discovery";
38import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";38import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
39import { googleOpenApiBundlePreset } from "./presets";39import { googleOpenApiBundlePreset } from "./presets";
@@ -83,7 +83,7 @@ const fetchGoogleBundleConversion = ( · 43 unmodified lines
83 ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents })));83 ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents })));
84 84
85const uniqueUrls = (urls: readonly string[]): readonly string[] => [85const 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
89const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {89const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
@@ -326,13 +326,14 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ · 236 unmodified lines
326 detect: ({ ctx, url }) =>326 detect: ({ ctx, url }) =>
327 Effect.gen(function* () {327 Effect.gen(function* () {
328 const trimmed = url.trim();328 const trimmed = url.trim();
329 if (!trimmed || !isGoogleDiscoveryUrl(trimmed)) return null;329 const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed);
330 if (!trimmed || !discoveryUrl) return null;
330 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;331 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
331 const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe(332 const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe(
332 Effect.provide(httpClientLayer),333 Effect.provide(httpClientLayer),
333 Effect.flatMap((documentText) =>334 Effect.flatMap((documentText) =>
334 convertGoogleDiscoveryBundleToOpenApi({335 convertGoogleDiscoveryBundleToOpenApi({
335 documents: [{ discoveryUrl: trimmed, documentText }],336 documents: [{ discoveryUrl, documentText }],
336 }),337 }),
337 ),338 ),
338 Effect.catch(() => Effect.succeed(null)),339 Effect.catch(() => Effect.succeed(null)),
@@ -341,7 +342,7 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ · 2 unmodified lines
341 return IntegrationDetectionResult.make({342 return IntegrationDetectionResult.make({
342 kind: "google",343 kind: "google",
343 confidence: "high",344 confidence: "high",
344 endpoint: trimmed,345 endpoint: discoveryUrl,
345 name: conversion.title,346 name: conversion.title,
346 slug: DEFAULT_GOOGLE_SLUG,347 slug: DEFAULT_GOOGLE_SLUG,
347 });348 });
packages/plugins/google/src/sdk/presets.ts+4−0
@@ -1,3 +1,5 @@
1import { normalizeGoogleDiscoveryUrl } from "./discovery";
2
1export interface GooglePreset {3export interface GooglePreset {
2 readonly id: string;4 readonly id: string;
3 readonly name: string;5 readonly name: string;
@@ -242,6 +244,8 @@ export const googleOAuthConsentScopesForPreset = (presetId: string): readonly st · 238 unmodified lines
242// ---------------------------------------------------------------------------244// ---------------------------------------------------------------------------
243 245
244const normalizeGooglePresetUrl = (url: string): string => {246const normalizeGooglePresetUrl = (url: string): string => {
247 const discoveryUrl = normalizeGoogleDiscoveryUrl(url);
248 if (discoveryUrl) return discoveryUrl;
245 const trimmed = url.trim();249 const trimmed = url.trim();
246 if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, "");250 if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, "");
247 const parsed = new URL(trimmed);251 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