From ef832f83f692537e8bc2841859880c2d09b66737 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 11:51:49 +0100 Subject: [PATCH] fix(extensions): guard model and Twilio fetches --- extensions/kilocode/provider-models.test.ts | 18 +++++++++-- extensions/kilocode/provider-models.ts | 6 +++- .../src/providers/twilio/api.test.ts | 31 ++++++++++--------- .../voice-call/src/providers/twilio/api.ts | 5 +-- 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/extensions/kilocode/provider-models.test.ts b/extensions/kilocode/provider-models.test.ts index 0f3e91d88b0..236e60c09d9 100644 --- a/extensions/kilocode/provider-models.test.ts +++ b/extensions/kilocode/provider-models.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from "vitest"; -import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js"; const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ fetchWithSsrFGuardMock: vi.fn(), @@ -7,8 +6,13 @@ const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, + ssrfPolicyFromHttpBaseUrlAllowedHostname: (baseUrl: string) => ({ + allowedHostnames: [new URL(baseUrl).hostname], + }), })); +import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js"; + type MockKilocodeFetchResponse = { ok: boolean; status?: number; @@ -79,9 +83,14 @@ async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () delete process.env.NODE_ENV; delete process.env.VITEST; + fetchWithSsrFGuardMock.mockReset(); + const callMockFetch = mockFetch as unknown as ( + url: string, + init?: RequestInit, + ) => Promise; fetchWithSsrFGuardMock.mockImplementation( async (params: { url: string; init?: RequestInit }) => ({ - response: await mockFetch(params.url, params.init), + response: await callMockFetch(params.url, params.init), release, }), ); @@ -89,7 +98,6 @@ async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () try { await runAssertions(); } finally { - fetchWithSsrFGuardMock.mockReset(); if (origNodeEnv === undefined) { delete process.env.NODE_ENV; } else { @@ -100,6 +108,7 @@ async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () } else { process.env.VITEST = origVitest; } + fetchWithSsrFGuardMock.mockReset(); } } @@ -141,6 +150,9 @@ describe("discoverKilocodeModels (fetch path)", () => { init: expect.objectContaining({ headers: { Accept: "application/json" }, }), + policy: { allowedHostnames: ["api.kilo.ai"] }, + timeoutMs: 5000, + auditContext: "kilocode.model_discovery", }), ); expect(mockFetch).toHaveBeenCalledWith( diff --git a/extensions/kilocode/provider-models.ts b/extensions/kilocode/provider-models.ts index 7d42b18726b..418352fee19 100644 --- a/extensions/kilocode/provider-models.ts +++ b/extensions/kilocode/provider-models.ts @@ -1,6 +1,9 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + fetchWithSsrFGuard, + ssrfPolicyFromHttpBaseUrlAllowedHostname, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; const log = createSubsystemLogger("kilocode-models"); @@ -142,6 +145,7 @@ export async function discoverKilocodeModels(): Promise headers: { Accept: "application/json" }, }, timeoutMs: DISCOVERY_TIMEOUT_MS, + policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(KILOCODE_BASE_URL), auditContext: "kilocode.model_discovery", }); try { diff --git a/extensions/voice-call/src/providers/twilio/api.test.ts b/extensions/voice-call/src/providers/twilio/api.test.ts index 28323227c72..4abdff7be03 100644 --- a/extensions/voice-call/src/providers/twilio/api.test.ts +++ b/extensions/voice-call/src/providers/twilio/api.test.ts @@ -1,22 +1,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; + +const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(), +})); + +vi.mock("../../../api.js", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + import { TwilioApiError, twilioApiRequest } from "./api.js"; -const apiMocks = vi.hoisted(() => ({ - fetchWithSsrFGuard: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: apiMocks.fetchWithSsrFGuard, -})); - describe("twilioApiRequest", () => { afterEach(() => { - apiMocks.fetchWithSsrFGuard.mockReset(); + fetchWithSsrFGuardMock.mockReset(); }); it("posts form bodies with basic auth and parses json", async () => { const release = vi.fn(async () => {}); - apiMocks.fetchWithSsrFGuard.mockResolvedValue({ + fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }), release, }); @@ -34,10 +35,12 @@ describe("twilioApiRequest", () => { }), ).resolves.toEqual({ sid: "CA123" }); - const [{ url, init, auditContext, policy }] = apiMocks.fetchWithSsrFGuard.mock.calls[0] ?? []; + const [{ url, init, auditContext, policy, timeoutMs }] = + fetchWithSsrFGuardMock.mock.calls[0] ?? []; expect(url).toBe("https://api.twilio.com/Calls.json"); expect(auditContext).toBe("voice-call.twilio.api"); expect(policy).toEqual({ allowedHostnames: ["api.twilio.com"] }); + expect(timeoutMs).toBe(30_000); expect(init).toEqual( expect.objectContaining({ method: "POST", @@ -63,7 +66,7 @@ describe("twilioApiRequest", () => { new Response("missing", { status: 404 }), ]; const release = vi.fn(async () => {}); - apiMocks.fetchWithSsrFGuard.mockImplementation(async () => ({ + fetchWithSsrFGuardMock.mockImplementation(async () => ({ response: responses.shift()!, release, })); @@ -93,7 +96,7 @@ describe("twilioApiRequest", () => { it("throws twilio api errors for non-ok responses", async () => { const release = vi.fn(async () => {}); - apiMocks.fetchWithSsrFGuard.mockResolvedValue({ + fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response("bad request", { status: 400 }), release, }); @@ -112,7 +115,7 @@ describe("twilioApiRequest", () => { it("exposes structured Twilio error codes from json error bodies", async () => { const release = vi.fn(async () => {}); - apiMocks.fetchWithSsrFGuard.mockResolvedValue({ + fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response( JSON.stringify({ code: 21220, diff --git a/extensions/voice-call/src/providers/twilio/api.ts b/extensions/voice-call/src/providers/twilio/api.ts index 9ba9a3c98c9..a41c37ee9a3 100644 --- a/extensions/voice-call/src/providers/twilio/api.ts +++ b/extensions/voice-call/src/providers/twilio/api.ts @@ -1,4 +1,4 @@ -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { fetchWithSsrFGuard } from "../../../api.js"; type ParsedTwilioApiError = { code?: number; @@ -61,8 +61,9 @@ export async function twilioApiRequest(params: { return acc; }, new URLSearchParams()); + const requestUrl = `${params.baseUrl}${params.endpoint}`; const { response, release } = await fetchWithSsrFGuard({ - url: `${params.baseUrl}${params.endpoint}`, + url: requestUrl, init: { method: "POST", headers: {