From eb130aa4e9ad5a37e871db1b38ada35fe53e3926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8B=BC=E5=93=A5?= Date: Sun, 5 Apr 2026 15:23:22 +0800 Subject: [PATCH] fix(google): disable pinned dns for image generation (#59873) * fix(google): restore proxy-safe image generation (#59873) * fix(ssrf): preserve transport policy without pinned dns * fix(ssrf): use undici fetch for dispatcher requests * fix(ssrf): type dispatcher fetch path --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../google/image-generation-provider.test.ts | 21 +++++++ .../google/image-generation-provider.ts | 1 + extensions/tlon/src/urbit/auth.ssrf.test.ts | 1 + src/infra/net/fetch-guard.ssrf.test.ts | 57 ++++++++++++++++++ src/infra/net/fetch-guard.ts | 58 ++++++++++++++++--- src/media-understanding/shared.test.ts | 47 ++++++++++++++- src/media-understanding/shared.ts | 14 ++++- 8 files changed, 190 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d4035d78f..6c920256ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai - Gateway/device pairing: require non-admin paired-device sessions to manage only their own device for token rotate/revoke and paired-device removal, blocking cross-device token theft inside pairing-scoped sessions. (#50627) Thanks @coygeek. - CLI/skills JSON: route `skills list --json`, `skills info --json`, and `skills check --json` output to stdout instead of stderr so machine-readable consumers receive JSON on the expected stream again. (#60914; fixes #57599; landed from contributor PR #57611 by @Aftabbs) Thanks @Aftabbs. - Agents/subagents: honor allowlist validation, auth-profile handoff, and session override state when a subagent retries after `LiveSessionModelSwitchError`. (#58178) Thanks @openperf. +- Google image generation: disable pinned DNS for Gemini image requests and honor explicit `pinDns` overrides in shared provider HTTP helpers so proxy-backed image generation works again. (#59873) Thanks @luoyanglang. - Agents/exec: restore `host=node` routing for node-pinned and `host=auto` sessions, while still blocking sandboxed `auto` sessions from jumping to gateway. (#60788) Thanks @openperf. - Agents/compaction: keep assistant tool calls and displaced tool results in the same compaction chunk so strict summarization providers stop rejecting orphaned tool pairs. (#58849) Thanks @openperf. - Outbound/sanitizer: strip leaked ``, ``, and model special tokens from shared user-visible assistant text, including truncated tool-call streams, so internal scaffolding no longer bleeds into replies across surfaces. (#60619) Thanks @oliviareid-svg. diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index 16d480d6d0a..32f12830670 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -1,4 +1,5 @@ import * as providerAuthRuntime from "openclaw/plugin-sdk/provider-auth-runtime"; +import * as providerHttp from "openclaw/plugin-sdk/provider-http"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js"; import { __testing as geminiWebSearchTesting } from "./src/gemini-web-search-provider.js"; @@ -257,6 +258,26 @@ describe("Google image-generation provider", () => { ); }); + it("disables DNS pinning for Google image generation requests", async () => { + mockGoogleApiKeyAuth(); + installGoogleFetchMock(); + const postJsonRequestSpy = vi.spyOn(providerHttp, "postJsonRequest"); + + const provider = buildGoogleImageGenerationProvider(); + await provider.generateImage({ + provider: "google", + model: "gemini-3.1-flash-image-preview", + prompt: "draw a fox", + cfg: {}, + }); + + expect(postJsonRequestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + pinDns: false, + }), + ); + }); + it("normalizes a configured bare Google host to the v1beta API root", async () => { mockGoogleApiKeyAuth(); const fetchMock = installGoogleFetchMock(); diff --git a/extensions/google/image-generation-provider.ts b/extensions/google/image-generation-provider.ts index d7ceb16546d..80cefa77d5f 100644 --- a/extensions/google/image-generation-provider.ts +++ b/extensions/google/image-generation-provider.ts @@ -160,6 +160,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider { }, timeoutMs: 60_000, fetchFn: fetch, + pinDns: false, allowPrivateNetwork, dispatcherPolicy, }); diff --git a/extensions/tlon/src/urbit/auth.ssrf.test.ts b/extensions/tlon/src/urbit/auth.ssrf.test.ts index 7e283bf831e..df75f4640ac 100644 --- a/extensions/tlon/src/urbit/auth.ssrf.test.ts +++ b/extensions/tlon/src/urbit/auth.ssrf.test.ts @@ -37,6 +37,7 @@ describe("tlon urbit auth ssrf", () => { const cookie = await authenticate("http://127.0.0.1:8080", "code", { ssrfPolicy: { allowPrivateNetwork: true }, lookupFn, + fetchImpl: mockFetch as typeof fetch, }); expect(cookie).toContain("urbauth-~zod=123"); expect(mockFetch).toHaveBeenCalled(); diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index b64f2d4a4c4..8a3fd3aa27f 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -6,6 +6,21 @@ import { } from "./fetch-guard.js"; import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js"; +const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({ + agentCtor: vi.fn(function MockAgent(this: { options: unknown }, options: unknown) { + this.options = options; + }), + envHttpProxyAgentCtor: vi.fn(function MockEnvHttpProxyAgent( + this: { options: unknown }, + options: unknown, + ) { + this.options = options; + }), + proxyAgentCtor: vi.fn(function MockProxyAgent(this: { options: unknown }, options: unknown) { + this.options = options; + }), +})); + function redirectResponse(location: string): Response { return new Response(null, { status: 302, @@ -108,6 +123,9 @@ describe("fetchWithSsrFGuard hardening", () => { afterEach(() => { vi.unstubAllEnvs(); + agentCtor.mockClear(); + envHttpProxyAgentCtor.mockClear(); + proxyAgentCtor.mockClear(); Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); }); @@ -251,6 +269,45 @@ describe("fetchWithSsrFGuard hardening", () => { } }); + it("keeps explicit proxy transport policy when DNS pinning is disabled", async () => { + const lookupFn = createPublicLookup(); + (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { + Agent: agentCtor, + EnvHttpProxyAgent: envHttpProxyAgentCtor, + ProxyAgent: proxyAgentCtor, + fetch: vi.fn(async () => okResponse()), + }; + const fetchImpl = vi.fn(async () => okResponse()); + + const result = await fetchWithSsrFGuard({ + url: "https://public.example/resource", + fetchImpl, + lookupFn, + pinDns: false, + dispatcherPolicy: { + mode: "explicit-proxy", + proxyUrl: "http://proxy.example:7890", + proxyTls: { + servername: "public.example", + }, + }, + }); + + expect(proxyAgentCtor).toHaveBeenCalledWith({ + uri: "http://proxy.example:7890", + requestTls: { + servername: "public.example", + }, + }); + expect(fetchImpl).toHaveBeenCalledWith( + "https://public.example/resource", + expect.objectContaining({ + dispatcher: expect.any(Object), + }), + ); + await result.release(); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = createPublicLookup(); const fetchImpl = await expectRedirectFailure({ diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index a2b64851a1d..3e14f581e21 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -15,6 +15,7 @@ import { import { loadUndiciRuntimeDeps } from "./undici-runtime.js"; type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; +type DispatcherAwareRequestInit = RequestInit & { dispatcher?: Dispatcher }; export const GUARDED_FETCH_MODE = { STRICT: "strict", @@ -93,6 +94,34 @@ function assertExplicitProxySupportsPinnedDns( } } +function createPolicyDispatcherWithoutPinnedDns( + dispatcherPolicy?: PinnedDispatcherPolicy, +): Dispatcher | null { + if (!dispatcherPolicy) { + return null; + } + const { Agent, EnvHttpProxyAgent, ProxyAgent } = loadUndiciRuntimeDeps(); + + if (dispatcherPolicy.mode === "direct") { + return new Agent(dispatcherPolicy.connect ? { connect: { ...dispatcherPolicy.connect } } : {}); + } + + if (dispatcherPolicy.mode === "env-proxy") { + return new EnvHttpProxyAgent({ + ...(dispatcherPolicy.connect ? { connect: { ...dispatcherPolicy.connect } } : {}), + ...(dispatcherPolicy.proxyTls ? { proxyTls: { ...dispatcherPolicy.proxyTls } } : {}), + }); + } + + const proxyUrl = dispatcherPolicy.proxyUrl.trim(); + return dispatcherPolicy.proxyTls + ? new ProxyAgent({ + uri: proxyUrl, + requestTls: { ...dispatcherPolicy.proxyTls }, + }) + : new ProxyAgent(proxyUrl); +} + async function assertExplicitProxyAllowed( dispatcherPolicy: PinnedDispatcherPolicy | undefined, lookupFn: LookupFn | undefined, @@ -180,6 +209,17 @@ function rewriteRedirectInitForMethod(params: { }; } +async function fetchWithRuntimeDispatcher( + input: string, + init: DispatcherAwareRequestInit, +): Promise { + const runtimeFetch = loadUndiciRuntimeDeps().fetch as unknown as ( + input: string, + init?: DispatcherAwareRequestInit, + ) => Promise; + return (await runtimeFetch(input, init)) as Response; +} + export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { const defaultFetch: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch; if (!defaultFetch) { @@ -238,22 +278,26 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { }); import { - postJsonRequest, fetchWithTimeoutGuarded, + postJsonRequest, + postTranscriptionRequest, readErrorResponse, resolveProviderHttpRequestConfig, } from "./shared.js"; @@ -223,4 +224,48 @@ describe("fetchWithTimeoutGuarded", () => { }), ); }); + + it("forwards explicit pinDns overrides to JSON requests", async () => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(null, { status: 200 }), + finalUrl: "https://example.com", + release: async () => {}, + }); + + await postJsonRequest({ + url: "https://api.example.com/v1/test", + headers: new Headers(), + body: { ok: true }, + fetchFn: fetch, + pinDns: false, + }); + + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + pinDns: false, + }), + ); + }); + + it("forwards explicit pinDns overrides to transcription requests", async () => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(null, { status: 200 }), + finalUrl: "https://example.com", + release: async () => {}, + }); + + await postTranscriptionRequest({ + url: "https://api.example.com/v1/transcriptions", + headers: new Headers(), + body: "audio-bytes", + fetchFn: fetch, + pinDns: false, + }); + + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + pinDns: false, + }), + ); + }); }); diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index 2de103cb8ec..673ed1cdf3e 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -117,6 +117,7 @@ export async function postTranscriptionRequest(params: { body: BodyInit; timeoutMs?: number; fetchFn: typeof fetch; + pinDns?: boolean; allowPrivateNetwork?: boolean; dispatcherPolicy?: PinnedDispatcherPolicy; auditContext?: string; @@ -130,9 +131,13 @@ export async function postTranscriptionRequest(params: { }, params.timeoutMs, params.fetchFn, - params.allowPrivateNetwork || params.dispatcherPolicy + params.allowPrivateNetwork || + params.dispatcherPolicy || + params.pinDns !== undefined || + params.auditContext ? { ...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}), + ...(params.pinDns !== undefined ? { pinDns: params.pinDns } : {}), ...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}), ...(params.auditContext ? { auditContext: params.auditContext } : {}), } @@ -146,6 +151,7 @@ export async function postJsonRequest(params: { body: unknown; timeoutMs?: number; fetchFn: typeof fetch; + pinDns?: boolean; allowPrivateNetwork?: boolean; dispatcherPolicy?: PinnedDispatcherPolicy; auditContext?: string; @@ -159,9 +165,13 @@ export async function postJsonRequest(params: { }, params.timeoutMs, params.fetchFn, - params.allowPrivateNetwork || params.dispatcherPolicy + params.allowPrivateNetwork || + params.dispatcherPolicy || + params.pinDns !== undefined || + params.auditContext ? { ...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}), + ...(params.pinDns !== undefined ? { pinDns: params.pinDns } : {}), ...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}), ...(params.auditContext ? { auditContext: params.auditContext } : {}), }