From 051c543bcb0239c4255a64794edb10d2f528fe61 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 21:27:36 +0100 Subject: [PATCH] fix: guard Google Meet API fetches --- extensions/google-meet/index.test.ts | 29 +++++++++++ extensions/google-meet/src/meet.ts | 36 +++++++++----- extensions/google-meet/src/oauth.ts | 73 ++++++++++++++++------------ 3 files changed, 95 insertions(+), 43 deletions(-) diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 7f202177a38..a1b2587ca36 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -24,6 +24,25 @@ const voiceCallMocks = vi.hoisted(() => ({ joinMeetViaVoiceCallGateway: vi.fn(async () => ({ callId: "call-1", dtmfSent: true })), })); +const fetchGuardMocks = vi.hoisted(() => ({ + fetchWithSsrFGuard: vi.fn( + async (params: { + url: string; + init?: RequestInit; + }): Promise<{ + response: Response; + release: () => Promise; + }> => ({ + response: await fetch(params.url, params.init), + release: vi.fn(async () => {}), + }), + ), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, +})); + vi.mock("./src/voice-call-gateway.js", () => ({ joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway, })); @@ -179,6 +198,16 @@ describe("google-meet plugin", () => { meeting: "spaces/abc-defg-hij", }), ).resolves.toMatchObject({ name: "spaces/abc-defg-hij" }); + expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://meet.googleapis.com/v2/spaces/abc-defg-hij", + init: expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token" }), + }), + policy: { allowedHostnames: ["meet.googleapis.com"] }, + auditContext: "google-meet.spaces.get", + }), + ); expect(fetchMock).toHaveBeenCalledWith( "https://meet.googleapis.com/v2/spaces/abc-defg-hij", expect.objectContaining({ diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 224eaf43e0f..5d38bd3dd61 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -1,5 +1,8 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; + const GOOGLE_MEET_API_BASE_URL = "https://meet.googleapis.com/v2"; const GOOGLE_MEET_URL_HOST = "meet.google.com"; +const GOOGLE_MEET_API_HOST = "meet.googleapis.com"; export type GoogleMeetSpace = { name: string; @@ -58,21 +61,30 @@ export async function fetchGoogleMeetSpace(params: { meeting: string; }): Promise { const name = normalizeGoogleMeetSpaceName(params.meeting); - const response = await fetch(`${GOOGLE_MEET_API_BASE_URL}/${encodeSpaceNameForPath(name)}`, { - headers: { - Authorization: `Bearer ${params.accessToken}`, - Accept: "application/json", + const { response, release } = await fetchWithSsrFGuard({ + url: `${GOOGLE_MEET_API_BASE_URL}/${encodeSpaceNameForPath(name)}`, + init: { + headers: { + Authorization: `Bearer ${params.accessToken}`, + Accept: "application/json", + }, }, + policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + auditContext: "google-meet.spaces.get", }); - if (!response.ok) { - const detail = await response.text(); - throw new Error(`Google Meet spaces.get failed (${response.status}): ${detail}`); + try { + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Google Meet spaces.get failed (${response.status}): ${detail}`); + } + const payload = (await response.json()) as GoogleMeetSpace; + if (!payload.name?.trim()) { + throw new Error("Google Meet spaces.get response was missing name"); + } + return payload; + } finally { + await release(); } - const payload = (await response.json()) as GoogleMeetSpace; - if (!payload.name?.trim()) { - throw new Error("Google Meet spaces.get response was missing name"); - } - return payload; } export function buildGoogleMeetPreflightReport(params: { diff --git a/extensions/google-meet/src/oauth.ts b/extensions/google-meet/src/oauth.ts index aa33939cd77..c78d64fd205 100644 --- a/extensions/google-meet/src/oauth.ts +++ b/extensions/google-meet/src/oauth.ts @@ -4,10 +4,12 @@ import { parseOAuthCallbackInput, waitForLocalOAuthCallback, } from "openclaw/plugin-sdk/provider-auth-runtime"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; export const GOOGLE_MEET_REDIRECT_URI = "http://localhost:8085/oauth2callback"; export const GOOGLE_MEET_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; export const GOOGLE_MEET_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_MEET_TOKEN_HOST = "oauth2.googleapis.com"; export const GOOGLE_MEET_SCOPES = [ "https://www.googleapis.com/auth/meetings.space.readonly", "https://www.googleapis.com/auth/meetings.conference.media.readonly", @@ -43,40 +45,49 @@ export function buildGoogleMeetAuthUrl(params: { } async function executeGoogleTokenRequest(body: URLSearchParams): Promise { - const response = await fetch(GOOGLE_MEET_TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "application/json", + const { response, release } = await fetchWithSsrFGuard({ + url: GOOGLE_MEET_TOKEN_URL, + init: { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "application/json", + }, + body, }, - body, + policy: { allowedHostnames: [GOOGLE_MEET_TOKEN_HOST] }, + auditContext: "google-meet.oauth.token", }); - if (!response.ok) { - const detail = await response.text(); - throw new Error(`Google OAuth token request failed (${response.status}): ${detail}`); + try { + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Google OAuth token request failed (${response.status}): ${detail}`); + } + const payload = (await response.json()) as { + access_token?: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + token_type?: string; + }; + const accessToken = payload.access_token?.trim(); + if (!accessToken) { + throw new Error("Google OAuth token response was missing access_token"); + } + const expiresInSeconds = + typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) + ? payload.expires_in + : 3600; + return { + accessToken, + expiresAt: Date.now() + expiresInSeconds * 1000, + refreshToken: payload.refresh_token?.trim() || undefined, + scope: payload.scope?.trim() || undefined, + tokenType: payload.token_type?.trim() || undefined, + }; + } finally { + await release(); } - const payload = (await response.json()) as { - access_token?: string; - expires_in?: number; - refresh_token?: string; - scope?: string; - token_type?: string; - }; - const accessToken = payload.access_token?.trim(); - if (!accessToken) { - throw new Error("Google OAuth token response was missing access_token"); - } - const expiresInSeconds = - typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) - ? payload.expires_in - : 3600; - return { - accessToken, - expiresAt: Date.now() + expiresInSeconds * 1000, - refreshToken: payload.refresh_token?.trim() || undefined, - scope: payload.scope?.trim() || undefined, - tokenType: payload.token_type?.trim() || undefined, - }; } function tokenRequestBody(values: Record): URLSearchParams {