fix: guard Google Meet API fetches

This commit is contained in:
Peter Steinberger
2026-04-23 21:27:36 +01:00
parent 59a8afe6fa
commit 051c543bcb
3 changed files with 95 additions and 43 deletions

View File

@@ -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<void>;
}> => ({
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({

View File

@@ -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<GoogleMeetSpace> {
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: {

View File

@@ -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<GoogleMeetOAuthTokens> {
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<string, string | undefined>): URLSearchParams {