mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 09:11:13 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -160,6 +160,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
|
||||
},
|
||||
timeoutMs: 60_000,
|
||||
fetchFn: fetch,
|
||||
pinDns: false,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user