test: inject web fetch dns lookup seams

This commit is contained in:
Peter Steinberger
2026-04-05 22:28:58 +01:00
parent 5586b3fd19
commit b4e5d91941
5 changed files with 47 additions and 46 deletions

View File

@@ -1,16 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LookupFn } from "../../infra/net/ssrf.js";
import * as logger from "../../logger.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
makeFetchHeaders,
} from "./web-fetch.test-harness.js";
import { createBaseWebFetchToolConfig, makeFetchHeaders } from "./web-fetch.test-harness.js";
import "./web-fetch.test-mocks.js";
import { createWebFetchTool } from "./web-tools.js";
const baseToolConfig = createBaseWebFetchToolConfig();
installWebFetchSsrfHarness();
const lookupMock = vi.fn();
const baseToolConfig = createBaseWebFetchToolConfig({
lookupFn: lookupMock as unknown as LookupFn,
});
function markdownResponse(body: string, extraHeaders: Record<string, string> = {}): Response {
return {
@@ -34,6 +33,21 @@ function htmlResponse(body: string): Response {
}
describe("web_fetch Cloudflare Markdown for Agents", () => {
const priorFetch = global.fetch;
beforeEach(() => {
lookupMock.mockImplementation(async (hostname: string) => {
void hostname;
return [{ address: "93.184.216.34", family: 4 }];
});
});
afterEach(() => {
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
it("sends Accept header preferring text/markdown", async () => {
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Test Page\n\nHello world."));
global.fetch = withFetchPreconnect(fetchSpy);

View File

@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import type { LookupFn } from "../../infra/net/ssrf.js";
import { type FetchMock, withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { makeFetchHeaders } from "./web-fetch.test-harness.js";
import "./web-fetch.test-mocks.js";
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
function redirectResponse(location: string): Response {
return {
@@ -59,6 +58,7 @@ async function createWebFetchToolForTest(params?: { firecrawlApiKey?: string })
},
},
},
lookupFn: lookupMock as unknown as LookupFn,
});
}
@@ -75,16 +75,12 @@ describe("web_fetch SSRF protection", () => {
beforeEach(() => {
vi.stubEnv("FIRECRAWL_API_KEY", "");
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
global.fetch = priorFetch;
lookupMock.mockClear();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it("blocks localhost hostnames before fetch/firecrawl", async () => {

View File

@@ -1,5 +1,4 @@
import { afterEach, beforeEach, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import type { LookupFn } from "../../infra/net/ssrf.js";
export function makeFetchHeaders(map: Record<string, string>): {
get: (key: string) => string | null;
@@ -9,26 +8,10 @@ export function makeFetchHeaders(map: Record<string, string>): {
};
}
export function installWebFetchSsrfHarness() {
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const priorFetch = global.fetch;
beforeEach(() => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
}
export function createBaseWebFetchToolConfig(opts?: { maxResponseBytes?: number }): {
export function createBaseWebFetchToolConfig(opts?: {
maxResponseBytes?: number;
lookupFn?: LookupFn;
}): {
config: {
tools: {
web: {
@@ -40,6 +23,7 @@ export function createBaseWebFetchToolConfig(opts?: { maxResponseBytes?: number
};
};
};
lookupFn?: LookupFn;
} {
return {
config: {
@@ -53,5 +37,6 @@ export function createBaseWebFetchToolConfig(opts?: { maxResponseBytes?: number
},
},
},
...(opts?.lookupFn ? { lookupFn: opts.lookupFn } : {}),
};
}

View File

@@ -1,6 +1,6 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import { SsrFBlockedError } from "../../infra/net/ssrf.js";
import { SsrFBlockedError, type LookupFn } from "../../infra/net/ssrf.js";
import { logDebug } from "../../logger.js";
import type { RuntimeWebFetchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js";
@@ -244,6 +244,7 @@ type WebFetchRuntimeParams = {
cacheTtlMs: number;
userAgent: string;
readabilityEnabled: boolean;
lookupFn?: LookupFn;
resolveProviderFallback: () => ReturnType<typeof resolveWebFetchDefinition>;
};
@@ -390,6 +391,7 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string
url: params.url,
maxRedirects: params.maxRedirects,
timeoutSeconds: params.timeoutSeconds,
lookupFn: params.lookupFn,
init: {
headers: {
Accept: "text/markdown, text/html;q=0.9, */*;q=0.1",
@@ -568,6 +570,7 @@ export function createWebFetchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebFetch?: RuntimeWebFetchMetadata;
lookupFn?: LookupFn;
}): AnyAgentTool | null {
const fetch = resolveFetchConfig(options?.config);
if (!resolveFetchEnabled({ fetch, sandboxed: options?.sandboxed })) {
@@ -618,6 +621,7 @@ export function createWebFetchTool(options?: {
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
userAgent,
readabilityEnabled,
lookupFn: options?.lookupFn,
resolveProviderFallback,
});
return jsonResult(result);

View File

@@ -1,6 +1,6 @@
import { EnvHttpProxyAgent } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import type { LookupFn } from "../../infra/net/ssrf.js";
import { resolveRequestUrl } from "../../plugin-sdk/request-url.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { makeFetchHeaders } from "./web-fetch.test-harness.js";
@@ -22,6 +22,8 @@ vi.mock("../../web-fetch/runtime.js", () => ({
}));
import { createWebFetchTool } from "./web-tools.js";
const lookupMock = vi.fn();
type MockResponse = {
ok: boolean;
status: number;
@@ -92,6 +94,7 @@ function createFetchTool(fetchOverrides: Record<string, unknown> = {}) {
},
},
sandboxed: false,
lookupFn: lookupMock as unknown as LookupFn,
});
}
@@ -143,19 +146,18 @@ describe("web_fetch extraction fallbacks", () => {
extractReadableContentMock.mockResolvedValue(null);
resolveWebFetchDefinitionMock.mockReset();
resolveWebFetchDefinitionMock.mockReturnValue(null);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
const addresses = ["93.184.216.34", "93.184.216.35"];
return {
hostname: normalized,
addresses,
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }),
};
lookupMock.mockImplementation(async (hostname: string) => {
void hostname;
return [
{ address: "93.184.216.34", family: 4 },
{ address: "93.184.216.35", family: 4 },
];
});
});
afterEach(() => {
global.fetch = priorFetch;
lookupMock.mockReset();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});