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 <vincentkoc@ieee.org>
This commit is contained in:
狼哥
2026-04-05 15:23:22 +08:00
committed by GitHub
parent 9238b98a7a
commit eb130aa4e9
8 changed files with 190 additions and 10 deletions

View File

@@ -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 `<tool_call>`, `<function_calls>`, 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.

View File

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

View File

@@ -160,6 +160,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
},
timeoutMs: 60_000,
fetchFn: fetch,
pinDns: false,
allowPrivateNetwork,
dispatcherPolicy,
});

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import {
import { loadUndiciRuntimeDeps } from "./undici-runtime.js";
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
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<Response> {
const runtimeFetch = loadUndiciRuntimeDeps().fetch as unknown as (
input: string,
init?: DispatcherAwareRequestInit,
) => Promise<unknown>;
return (await runtimeFetch(input, init)) as Response;
}
export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<GuardedFetchResult> {
const defaultFetch: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch;
if (!defaultFetch) {
@@ -238,22 +278,26 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
if (canUseTrustedEnvProxy) {
const { EnvHttpProxyAgent } = loadUndiciRuntimeDeps();
dispatcher = new EnvHttpProxyAgent();
} else if (params.pinDns !== false) {
} else if (params.pinDns === false) {
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
} else {
dispatcher = createPinnedDispatcher(pinned, params.dispatcherPolicy, params.policy);
}
const init: RequestInit & { dispatcher?: Dispatcher } = {
const init: DispatcherAwareRequestInit = {
...(currentInit ? { ...currentInit } : {}),
redirect: "manual",
...(dispatcher ? { dispatcher } : {}),
...(signal ? { signal } : {}),
};
// Keep the caller-provided/global fetch implementation on the hot path so
// tests can stub network behavior while still receiving the pinned
// dispatcher in RequestInit.
const fetcher = defaultFetch;
const response = await fetcher(parsedUrl.toString(), init);
// Use caller-provided fetch stubs when present; otherwise fall back to
// undici's fetch whenever we attach a dispatcher because the global fetch
// path will not honor per-request dispatchers.
const response =
dispatcher && !params.fetchImpl
? await fetchWithRuntimeDispatcher(parsedUrl.toString(), init)
: await defaultFetch(parsedUrl.toString(), init);
if (isRedirectStatus(response.status)) {
const location = response.headers.get("location");

View File

@@ -15,8 +15,9 @@ vi.mock("../infra/net/fetch-guard.js", async () => {
});
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,
}),
);
});
});

View File

@@ -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 } : {}),
}