fix(extensions): guard channel runtime fetches

This commit is contained in:
Peter Steinberger
2026-04-17 17:27:09 +01:00
parent c580933623
commit 41ef752dd8
4 changed files with 60 additions and 29 deletions

View File

@@ -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<Record<string, string>> {
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<string, string>;
cachedCerts = { fetchedAt: now, certs };
return certs;
} finally {
await release();
}
const certs = (await res.json()) as Record<string, string>;
cachedCerts = { fetchedAt: now, certs };
return certs;
}
export type GoogleChatAudienceType = "app-url" | "project-number";

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>)[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<string, unknown>)[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",

View File

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