diff --git a/extensions/googlechat/src/auth.ts b/extensions/googlechat/src/auth.ts index 9845aefb043..6454e88bda1 100644 --- a/extensions/googlechat/src/auth.ts +++ b/extensions/googlechat/src/auth.ts @@ -1,4 +1,5 @@ import { GoogleAuth, OAuth2Client } from "google-auth-library"; +import { fetchWithSsrFGuard } from "../runtime-api.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot"; @@ -83,13 +84,20 @@ async function fetchChatCerts(): Promise> { if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) { return cachedCerts.certs; } - const res = await fetch(CHAT_CERTS_URL); - if (!res.ok) { - throw new Error(`Failed to fetch Chat certs (${res.status})`); + const { response, release } = await fetchWithSsrFGuard({ + url: CHAT_CERTS_URL, + auditContext: "googlechat.auth.certs", + }); + try { + if (!response.ok) { + throw new Error(`Failed to fetch Chat certs (${response.status})`); + } + const certs = (await response.json()) as Record; + cachedCerts = { fetchedAt: now, certs }; + return certs; + } finally { + await release(); } - const certs = (await res.json()) as Record; - cachedCerts = { fetchedAt: now, certs }; - return certs; } export type GoogleChatAudienceType = "app-url" | "project-number"; diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index 00f29a98792..724cd8aa399 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ response: await fetch(params.url, params.init), release: async () => {}, })), + verifySignedJwtWithCertsAsync: vi.fn(), verifyIdToken: vi.fn(), getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"), })); @@ -28,6 +29,7 @@ vi.mock("google-auth-library", () => ({ GoogleAuth: function GoogleAuth() {}, OAuth2Client: class { verifyIdToken = mocks.verifyIdToken; + verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync; }, })); @@ -293,4 +295,34 @@ describe("verifyGoogleChatRequest", () => { reason: "unexpected add-on principal: principal-2", }); }); + + it("fetches Chat certs through the guarded fetch for project-number tokens", async () => { + const release = vi.fn(); + mocks.fetchWithSsrFGuard.mockClear(); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ "kid-1": "cert-body" }), { status: 200 }), + release, + }); + mocks.verifySignedJwtWithCertsAsync.mockReset().mockResolvedValue(undefined); + + await expect( + verifyGoogleChatRequest({ + bearer: "token", + audienceType: "project-number", + audience: "123456789", + }), + ).resolves.toEqual({ ok: true }); + + expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({ + url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com", + auditContext: "googlechat.auth.certs", + }); + expect(mocks.verifySignedJwtWithCertsAsync).toHaveBeenCalledWith( + "token", + { "kid-1": "cert-body" }, + "123456789", + ["chat@system.gserviceaccount.com"], + ); + expect(release).toHaveBeenCalledOnce(); + }); }); diff --git a/extensions/matrix/src/matrix/sdk/transport.test.ts b/extensions/matrix/src/matrix/sdk/transport.test.ts index 26095563c22..ab85767573b 100644 --- a/extensions/matrix/src/matrix/sdk/transport.test.ts +++ b/extensions/matrix/src/matrix/sdk/transport.test.ts @@ -8,6 +8,15 @@ function clearTestUndiciRuntimeDepsOverride(): void { Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); } +function stubRuntimeFetch(fetchImpl: typeof fetch): void { + (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { + Agent: function MockAgent() {}, + EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {}, + ProxyAgent: function MockProxyAgent() {}, + fetch: fetchImpl, + }; +} + describe("performMatrixRequest", () => { beforeEach(() => { vi.unstubAllGlobals(); @@ -19,8 +28,7 @@ describe("performMatrixRequest", () => { }); it("rejects oversized raw responses before buffering the whole body", async () => { - vi.stubGlobal( - "fetch", + stubRuntimeFetch( vi.fn( async () => new Response("too-big", { @@ -55,8 +63,7 @@ describe("performMatrixRequest", () => { controller.close(); }, }); - vi.stubGlobal( - "fetch", + stubRuntimeFetch( vi.fn( async () => new Response(stream, { @@ -87,8 +94,7 @@ describe("performMatrixRequest", () => { controller.enqueue(new Uint8Array([1, 2, 3])); }, }); - vi.stubGlobal( - "fetch", + stubRuntimeFetch( vi.fn( async () => new Response(stream, { @@ -135,12 +141,7 @@ describe("performMatrixRequest", () => { }, }); }); - (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { - Agent: function MockAgent() {}, - EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {}, - ProxyAgent: function MockProxyAgent() {}, - fetch: runtimeFetch, - }; + stubRuntimeFetch(runtimeFetch); const result = await performMatrixRequest({ homeserver: "http://127.0.0.1:8008", diff --git a/extensions/matrix/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts index 48630c4067e..f3dc802bbdf 100644 --- a/extensions/matrix/src/matrix/sdk/transport.ts +++ b/extensions/matrix/src/matrix/sdk/transport.ts @@ -89,13 +89,6 @@ function buildBufferedResponse(params: { return response; } -function isMockedFetch(fetchImpl: typeof fetch | undefined): boolean { - if (typeof fetchImpl !== "function") { - return false; - } - return typeof (fetchImpl as typeof fetch & { mock?: unknown }).mock === "object"; -} - async function fetchWithMatrixDispatcher(params: { url: string; init: MatrixDispatcherRequestInit; @@ -104,10 +97,7 @@ async function fetchWithMatrixDispatcher(params: { // fetches must stay fail-closed unless a retry path can preserve the // validated pinned-address binding. Route dispatcher-attached requests // through undici runtime fetch so the pinned dispatcher is preserved. - if (params.init.dispatcher && !isMockedFetch(globalThis.fetch)) { - return await fetchWithRuntimeDispatcher(params.url, params.init); - } - return await fetch(params.url, params.init); + return await fetchWithRuntimeDispatcher(params.url, params.init); } async function fetchWithMatrixGuardedRedirects(params: {