mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(web-fetch): add tools.web.fetch.headers for operator request headers (#115545)
* feat(web-fetch): add tools.web.fetch.headers for operator request headers * docs(web-fetch): update cache discriminator comment * fix(web-fetch): reserve runtime and cookie headers * fix(web-fetch): harden operator header normalization * fix(web-fetch): align header safety contracts * fix(web-fetch): close header logging gaps * fix(web-fetch): report case-colliding headers * fix(web-fetch): reject stale colliding headers * fix(web-fetch): refuse credential token aliases * fix(web-fetch): preserve empty header values * fix(web-fetch): refuse credential-shaped headers * chore(config): refresh web fetch header baselines * test(web-fetch): cover header security contracts * fix(web-fetch): narrow credential header refusal * fix(web-fetch): preserve trace metadata headers * fix(web-fetch): keep header validation internal * test(web-fetch): satisfy strict test contracts * fix(web-fetch): refuse vendor credential headers * fix(web-fetch): refuse authentication signatures * fix(web-fetch): detect qualified auth signatures * fix(web-fetch): refuse auth-suffixed headers * fix(web-fetch): refuse compact credential headers * fix(secrets): audit authentication signatures * fix(web-fetch): redact operator headers in captures * fix(web-fetch): keep capture metadata internal * fix(web-fetch): allow sensitive operator headers * test(web-fetch): cover normalized operator headers * docs: note web fetch request headers * chore(config): refresh web fetch header baseline * chore: remove release-owned changelog entry
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2307,
|
||||
"core": 2309,
|
||||
"channel": 3692,
|
||||
"plugin": 4057
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
6b02737084409d3a0c92611049077284826bafbe29e7227b3eda591ead80c2e3 config-baseline.json
|
||||
b89715475e4b18a0d32765fda42bcce38537f6d49f949bb4ff0c7d1630101882 config-baseline.core.json
|
||||
f1b338718b9a058fc3b472a2004c15d146aa83303804c76f7ea831ed3f5593b8 config-baseline.json
|
||||
063a781ea045218fcf4f8ddc7df152b89eaf9a2ab8ed8049cafce75301425a3a config-baseline.core.json
|
||||
e9a81ee89ff032033012413e161316e4d07e8f6b206382a25fed2f8485151b5e config-baseline.channel.json
|
||||
c097c0bee74849e691bf295b070ce189d4e0e501bd770e448922d9f399c4901f config-baseline.plugin.json
|
||||
|
||||
@@ -101,6 +101,10 @@ progress line is channel UI state only and never contains fetched page content.
|
||||
useTrustedEnvProxy: false, // let a trusted HTTP(S) env proxy resolve DNS
|
||||
readability: true, // use Readability extraction
|
||||
userAgent: "Mozilla/5.0 ...", // override User-Agent
|
||||
headers: {
|
||||
// optional; every value is treated as sensitive
|
||||
"X-Routing-Target": "staging",
|
||||
},
|
||||
ssrfPolicy: {
|
||||
allowRfc2544BenchmarkRange: true, // opt-in for trusted fake-IP proxies using 198.18.0.0/15
|
||||
allowIpv6UniqueLocalRange: true, // opt-in for trusted fake-IP proxies using fc00::/7
|
||||
@@ -173,6 +177,63 @@ Current runtime behavior:
|
||||
- If Readability is disabled, `web_fetch` skips straight to the selected
|
||||
provider fallback. If no provider is available, it fails closed.
|
||||
|
||||
## Custom request headers
|
||||
|
||||
Set `tools.web.fetch.headers` when your deployment needs extra request metadata on
|
||||
outbound fetches, such as a routing or service-injection header that steers traffic
|
||||
to a gateway you control.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
headers: {
|
||||
"X-Routing-Target": "${WEB_FETCH_ROUTING_TARGET}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Every configured value is treated as sensitive and redacted from exposed config
|
||||
and debug captures. The headers are still sent to every initial URL `web_fetch`
|
||||
requests, and the model chooses that URL. Configure credential headers only when
|
||||
that is the intended trust boundary.
|
||||
</Warning>
|
||||
|
||||
Behavior worth knowing:
|
||||
|
||||
- Values are plain strings and support `${VAR}` environment substitution like any
|
||||
other config string. Structured SecretRef values are not accepted.
|
||||
- Headers apply only to the direct `web_fetch` request. Provider fallbacks such as
|
||||
[Firecrawl](/tools/firecrawl) call their own API and never receive these headers.
|
||||
- Entries are validated when the request is built, not at config load, so one bad
|
||||
entry is dropped while the rest still apply. Config load stays permissive on
|
||||
purpose: a fail-closed validation error over a single header-name typo would
|
||||
disable the whole surface. Every dropped entry is logged by name.
|
||||
- Dropped names:
|
||||
- `Accept`, `Accept-Language`, and `User-Agent` belong to the fetch and
|
||||
readability contract. Use `tools.web.fetch.userAgent` for the user agent.
|
||||
- Framing and hop-by-hop names such as `Content-Length`, `Transfer-Encoding`,
|
||||
`Connection`, and `Upgrade`, which a request either rejects outright or ignores.
|
||||
- Names that are not valid HTTP tokens, such as `"X Routing Target"`.
|
||||
- Dropped values: bytes a request cannot carry (CR, LF, NUL, or any character above
|
||||
`U+00FF`). Missing environment variables are reported by config loading; the global
|
||||
`$${VAR}` escape remains available when the literal `${VAR}` text is intentional.
|
||||
- Two entries whose names differ only in case collapse to the later entry, so a
|
||||
request never carries a comma-joined value the receiving gateway cannot parse.
|
||||
The dropped name is logged without either value. If the later entry is unusable,
|
||||
neither value is sent.
|
||||
- Rejection happens before the cache key is computed, so the key always matches the
|
||||
bytes actually sent: changing a header that is really sent partitions the fetch
|
||||
cache, while adding one that gets dropped does not.
|
||||
- When a redirect crosses origins, the guarded-fetch safe allowlist is applied.
|
||||
Routing headers outside that list are dropped; standard safe headers such as
|
||||
`Cache-Control`, `Content-Type`, and `Range` are preserved.
|
||||
|
||||
## Trusted env proxy
|
||||
|
||||
If your deployment requires `web_fetch` to go through a trusted outbound
|
||||
@@ -200,6 +261,9 @@ outbound policy after DNS resolution.
|
||||
for trusted fake-IP proxy stacks; leave them unset unless your proxy owns
|
||||
those synthetic ranges and enforces its own destination policy
|
||||
- Redirects are checked and limited by `maxRedirects` (default `3`)
|
||||
- `tools.web.fetch.headers` values are redacted from exposed config and debug
|
||||
captures, sent to the initial fetched host, and retained on redirects only when
|
||||
the existing guarded-fetch policy allows them
|
||||
- `useTrustedEnvProxy` is an explicit opt-in and should only be enabled for
|
||||
operator-controlled proxies that still enforce outbound policy after DNS
|
||||
resolution
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// tools.web.fetch.headers tests cover operator header delivery, transport
|
||||
// constraints, sensitive capture metadata, and cache partitioning.
|
||||
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 "./web-fetch.test-mocks.js";
|
||||
import { createWebFetchTool } from "./web-fetch.js";
|
||||
import * as webGuardedFetch from "./web-guarded-fetch.js";
|
||||
|
||||
const lookupMock = vi.fn();
|
||||
|
||||
function markdownResponse(body: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
function createToolWithHeaders(
|
||||
headers: Record<string, string>,
|
||||
opts?: { cacheTtlMinutes?: number },
|
||||
): ReturnType<typeof createWebFetchTool> {
|
||||
return createWebFetchTool({
|
||||
lookupFn: lookupMock as unknown as LookupFn,
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: { cacheTtlMinutes: opts?.cacheTtlMinutes ?? 0, headers },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestHeaders(
|
||||
fetchSpy: ReturnType<typeof vi.fn>,
|
||||
callIndex = 0,
|
||||
): Record<string, string> {
|
||||
const call = fetchSpy.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`expected fetch call at index ${callIndex}`);
|
||||
}
|
||||
return (call[1] as { headers?: Record<string, string> } | undefined)?.headers ?? {};
|
||||
}
|
||||
|
||||
describe("web_fetch configured request headers", () => {
|
||||
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 configured headers with the direct fetch request", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Routed"));
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
const fetchWithRealGuard = webGuardedFetch.fetchWithWebToolsNetworkGuard;
|
||||
const guardedFetchSpy = vi
|
||||
.spyOn(webGuardedFetch, "fetchWithWebToolsNetworkGuard")
|
||||
.mockImplementation((params) => fetchWithRealGuard(params));
|
||||
|
||||
const tool = createToolWithHeaders({
|
||||
"ATL-SG-SERVICE-INJECTION-URL": "http://host.docker.internal:9999",
|
||||
Authorization: "Bearer deployment-token",
|
||||
Cookie: "session=deployment-session",
|
||||
"X-Api-Key": "deployment-api-key",
|
||||
"X-Tokenizer-Version": "v2",
|
||||
"X-Trace-Token": "trace-context",
|
||||
});
|
||||
|
||||
await tool?.execute?.("call", { url: "https://example.com/routed" });
|
||||
|
||||
expect(getRequestHeaders(fetchSpy)["ATL-SG-SERVICE-INJECTION-URL"]).toBe(
|
||||
"http://host.docker.internal:9999",
|
||||
);
|
||||
expect(getRequestHeaders(fetchSpy).Authorization).toBe("Bearer deployment-token");
|
||||
expect(getRequestHeaders(fetchSpy).Cookie).toBe("session=deployment-session");
|
||||
expect(getRequestHeaders(fetchSpy)["X-Api-Key"]).toBe("deployment-api-key");
|
||||
expect(getRequestHeaders(fetchSpy)["X-Tokenizer-Version"]).toBe("v2");
|
||||
expect(getRequestHeaders(fetchSpy)["X-Trace-Token"]).toBe("trace-context");
|
||||
const captureMeta = guardedFetchSpy.mock.calls[0]?.[0].capture;
|
||||
expect(captureMeta === false ? undefined : captureMeta?.sensitiveRequestHeaderNames).toEqual([
|
||||
"ATL-SG-SERVICE-INJECTION-URL",
|
||||
"Authorization",
|
||||
"Cookie",
|
||||
"X-Api-Key",
|
||||
"X-Tokenizer-Version",
|
||||
"X-Trace-Token",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses fetch-owned and framing headers", async () => {
|
||||
// Upgrade/Expect/Keep-Alive/Transfer-Encoding make fetch throw, so letting one
|
||||
// through would break every web_fetch call.
|
||||
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Connection"));
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
|
||||
const tool = createToolWithHeaders({
|
||||
accept: "text/plain",
|
||||
"user-agent": "operator-agent/1.0",
|
||||
"Accept-Language": "de-DE",
|
||||
"Sec-Fetch-Mode": "same-origin",
|
||||
Upgrade: "h2c",
|
||||
Expect: "100-continue",
|
||||
"Keep-Alive": "timeout=5",
|
||||
"Transfer-Encoding": "chunked",
|
||||
Host: "elsewhere.example",
|
||||
Connection: "close",
|
||||
"Content-Length": "0",
|
||||
"X-Routing-Target": "staging",
|
||||
});
|
||||
|
||||
const result = await tool?.execute?.("call", { url: "https://example.com/connection" });
|
||||
|
||||
expect((result?.details as { status?: number } | undefined)?.status).toBe(200);
|
||||
expect(Object.keys(getRequestHeaders(fetchSpy))).toEqual([
|
||||
"Accept",
|
||||
"User-Agent",
|
||||
"Accept-Language",
|
||||
"X-Routing-Target",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes empty values without retaining a stale case-colliding value", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Normalized"));
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
|
||||
const tool = createToolWithHeaders({
|
||||
"X-Presence-Flag": " \t ",
|
||||
"X-Routing-Target": "staging",
|
||||
"x-routing-target": "東京",
|
||||
});
|
||||
|
||||
await tool?.execute?.("call", { url: "https://example.com/normalized" });
|
||||
|
||||
const headers = getRequestHeaders(fetchSpy);
|
||||
expect(headers["X-Presence-Flag"]).toBe("");
|
||||
expect(headers["X-Routing-Target"]).toBeUndefined();
|
||||
expect(headers["x-routing-target"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still fetches when every configured header is unusable, naming it in a warning", async () => {
|
||||
// The whole request must not fail because one config value is unsendable.
|
||||
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Survives"));
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
const warnSpy = vi.spyOn(logger, "logWarn").mockImplementation(() => {});
|
||||
|
||||
const invalidName = "X-Bad\n[forged]\u001b[31m";
|
||||
const tool = createToolWithHeaders({
|
||||
"X-Survives-Unicode": "東京",
|
||||
[invalidName]: "ignored",
|
||||
});
|
||||
|
||||
const result = await tool?.execute?.("call", { url: "https://example.com/survives" });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect((result?.details as { status?: number } | undefined)?.status).toBe(200);
|
||||
const warned = warnSpy.mock.calls
|
||||
.map(([message]) => message)
|
||||
.filter((message) => message.includes("X-Survives-Unicode"));
|
||||
expect(warned).toHaveLength(1);
|
||||
// Names are safe to log; values are not.
|
||||
expect(warned[0]).not.toContain("東京");
|
||||
const invalidNameWarning = warnSpy.mock.calls
|
||||
.map(([message]) => message)
|
||||
.find((message) => message.includes("X-Bad"));
|
||||
expect(invalidNameWarning).toContain(JSON.stringify(invalidName));
|
||||
expect(invalidNameWarning).not.toContain("\n");
|
||||
expect(invalidNameWarning).not.toContain("\u001b");
|
||||
});
|
||||
|
||||
it("partitions the fetch cache by configured headers", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Cached"));
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
const url = "https://example.com/cache-partition";
|
||||
|
||||
await createToolWithHeaders(
|
||||
{ "ATL-SG-SERVICE-INJECTION-URL": "http://host.docker.internal:9999" },
|
||||
{ cacheTtlMinutes: 15 },
|
||||
)?.execute?.("call", { url });
|
||||
await createToolWithHeaders(
|
||||
{ "ATL-SG-SERVICE-INJECTION-URL": "http://host.docker.internal:8888" },
|
||||
{ cacheTtlMinutes: 15 },
|
||||
)?.execute?.("call", { url });
|
||||
// Same header set must still hit the cache written by the first call.
|
||||
await createToolWithHeaders(
|
||||
{ "ATL-SG-SERVICE-INJECTION-URL": "http://host.docker.internal:9999" },
|
||||
{ cacheTtlMinutes: 15 },
|
||||
)?.execute?.("call", { url });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getRequestHeaders(fetchSpy, 1)["ATL-SG-SERVICE-INJECTION-URL"]).toBe(
|
||||
"http://host.docker.internal:8888",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not partition the cache for headers the request never carries", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Unpartitioned"));
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
const url = "https://example.com/cache-unpartitioned";
|
||||
|
||||
const plain = createWebFetchTool({
|
||||
lookupFn: lookupMock as unknown as LookupFn,
|
||||
config: { tools: { web: { fetch: { cacheTtlMinutes: 15 } } } },
|
||||
});
|
||||
await plain?.execute?.("call", { url });
|
||||
// A refused or dropped header sends a byte-identical request, so both must
|
||||
// share the entry: rejection happens before the cache key is computed.
|
||||
await createToolWithHeaders({ accept: "text/plain" }, { cacheTtlMinutes: 15 })?.execute?.(
|
||||
"call",
|
||||
{ url },
|
||||
);
|
||||
await createToolWithHeaders(
|
||||
{ "X Invalid Name": "dropped", "Transfer-Encoding": "chunked" },
|
||||
{ cacheTtlMinutes: 15 },
|
||||
)?.execute?.("call", { url });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -362,6 +362,99 @@ describe("web_fetch provider fallback normalization", () => {
|
||||
expect(secondDetails.cached).toBeUndefined();
|
||||
});
|
||||
|
||||
it("late-binds direct request headers and partitions the cache by the refreshed values", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response("# Routed", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/markdown; charset=utf-8" },
|
||||
}),
|
||||
);
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
const tool = createWebFetchTool({
|
||||
config: {} as OpenClawConfig,
|
||||
sandboxed: false,
|
||||
lateBindRuntimeConfig: true,
|
||||
});
|
||||
const url = "https://example.com/late-bound-request-headers";
|
||||
|
||||
runtimeState.activeSecretsRuntimeSnapshot = {
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
cacheTtlMinutes: 15,
|
||||
headers: { "X-Routing-Target": "staging-a" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await tool?.execute?.("late-bound-header-a", { url });
|
||||
|
||||
runtimeState.activeSecretsRuntimeSnapshot = {
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
cacheTtlMinutes: 15,
|
||||
headers: { "X-Routing-Target": "staging-b" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await tool?.execute?.("late-bound-header-b", { url });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
const firstHeaders = fetchSpy.mock.calls[0]?.[1]?.headers as Record<string, string> | undefined;
|
||||
const secondHeaders = fetchSpy.mock.calls[1]?.[1]?.headers as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(firstHeaders?.["X-Routing-Target"]).toBe("staging-a");
|
||||
expect(secondHeaders?.["X-Routing-Target"]).toBe("staging-b");
|
||||
});
|
||||
|
||||
it("does not pass operator request headers to provider fallbacks", async () => {
|
||||
global.fetch = withFetchPreconnect(
|
||||
vi.fn(async () => {
|
||||
throw new Error("network failed");
|
||||
}),
|
||||
);
|
||||
const providerExecute = vi.fn(async (_input: unknown) => ({ text: "provider body" }));
|
||||
resolveWebFetchDefinitionMock.mockReturnValue({
|
||||
provider: { id: "firecrawl" },
|
||||
definition: {
|
||||
description: "firecrawl",
|
||||
parameters: {},
|
||||
execute: providerExecute,
|
||||
},
|
||||
});
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
headers: { "X-Routing-Target": "direct-only" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
sandboxed: false,
|
||||
});
|
||||
|
||||
await tool?.execute?.("provider-header-isolation", {
|
||||
url: "https://example.com/provider-header-isolation",
|
||||
});
|
||||
|
||||
const providerInput = providerExecute.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
|
||||
expect(providerInput).toEqual({
|
||||
url: "https://example.com/provider-header-isolation",
|
||||
extractMode: "markdown",
|
||||
maxChars: 20_000,
|
||||
});
|
||||
expect(providerInput).not.toHaveProperty("headers");
|
||||
});
|
||||
|
||||
it("cancels an unread error response when provider fallback succeeds", async () => {
|
||||
let cancelled = false;
|
||||
global.fetch = withFetchPreconnect(
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Type } from "typebox";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { sha256Hex } from "../../infra/crypto-digest.js";
|
||||
import { SsrFBlockedError, type LookupFn, type SsrFPolicy } from "../../infra/net/ssrf.js";
|
||||
import { logDebug } from "../../logger.js";
|
||||
import { logDebug, logWarn } from "../../logger.js";
|
||||
import { assertSecretOwnerAvailable } from "../../secrets/runtime-degraded-state.js";
|
||||
import { runtimeWebSecretOwnerId } from "../../secrets/runtime-web-secret-owner.js";
|
||||
import type { RuntimeWebFetchMetadata } from "../../secrets/runtime-web-tools.types.js";
|
||||
@@ -71,6 +71,26 @@ const DEFAULT_FETCH_USER_AGENT =
|
||||
|
||||
const FETCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||
|
||||
// Accept and Accept-Language are part of the fetch/readability contract,
|
||||
// User-Agent has its own tools.web.fetch.userAgent key, and Undici owns
|
||||
// Sec-Fetch-Mode.
|
||||
const FETCH_BLOCKED_HEADER_NAMES = new Set([
|
||||
"accept",
|
||||
"accept-language",
|
||||
"user-agent",
|
||||
"sec-fetch-mode",
|
||||
"connection",
|
||||
"content-length",
|
||||
"expect",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
const WebFetchSchema = Type.Object({
|
||||
url: Type.String({ description: "HTTP(S) URL." }),
|
||||
extractMode: Type.Optional(
|
||||
@@ -183,6 +203,79 @@ function resolveFetchUseTrustedEnvProxy(fetch?: WebFetchConfig): boolean {
|
||||
return fetch?.useTrustedEnvProxy === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator headers web_fetch may actually send. Every dropped entry gets its own
|
||||
* warning: a silently ignored routing header looks exactly like working egress.
|
||||
* Header names are safe to log; values are not.
|
||||
*/
|
||||
function resolveFetchHeaders(fetch?: WebFetchConfig): Record<string, string> | undefined {
|
||||
const configured = fetch?.headers;
|
||||
if (!configured) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = new Map<string, { name: string; value: string }>();
|
||||
for (const [rawName, rawValue] of Object.entries(configured)) {
|
||||
const name = rawName.trim();
|
||||
const lowerName = name.toLowerCase();
|
||||
const prior = resolved.get(lowerName);
|
||||
if (prior) {
|
||||
resolved.delete(lowerName);
|
||||
logWarn(
|
||||
`[web-fetch] dropped case-colliding tools.web.fetch.headers entry: ${JSON.stringify(prior.name)}`,
|
||||
);
|
||||
}
|
||||
let value: string;
|
||||
try {
|
||||
value = new Headers([[name, rawValue]]).get(name) ?? "";
|
||||
} catch {
|
||||
logWarn(
|
||||
`[web-fetch] dropped tools.web.fetch.headers entry a request cannot carry: ${JSON.stringify(rawName)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (FETCH_BLOCKED_HEADER_NAMES.has(lowerName)) {
|
||||
logWarn(`[web-fetch] dropped reserved or framing tools.web.fetch.headers entry: ${name}`);
|
||||
continue;
|
||||
}
|
||||
resolved.set(lowerName, { name, value });
|
||||
}
|
||||
const entries = [...resolved.values()]
|
||||
.map(({ name, value }) => [name, value] as const)
|
||||
.toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret-free cache discriminator for operator headers. The fetch cache is a
|
||||
* process-wide map and routing headers can point the same URL at a different
|
||||
* backend, so the header set must partition the cache without storing its values.
|
||||
*/
|
||||
function resolveFetchHeadersCacheKey(headers?: Record<string, string>): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
return sha256Hex(JSON.stringify(Object.entries(headers)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the outgoing header record. Fetch-owned headers keep their canonical
|
||||
* casing and order because a plain record reaches the wire verbatim: undici does
|
||||
* not re-normalize it, so switching to `Headers` here would change the request
|
||||
* fingerprint of every fetch, including ones with no configured headers.
|
||||
* `resolveFetchHeaders` has already removed anything that could collide.
|
||||
*/
|
||||
function buildWebFetchRequestHeaders(params: {
|
||||
userAgent: string;
|
||||
operatorHeaders?: Record<string, string>;
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
Accept: "text/markdown, text/html;q=0.9, */*;q=0.1",
|
||||
"User-Agent": params.userAgent,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
...params.operatorHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFetchMaxCharsCap(fetch?: WebFetchConfig): number {
|
||||
const raw =
|
||||
fetch && "maxCharsCap" in fetch && typeof fetch.maxCharsCap === "number"
|
||||
@@ -428,6 +521,7 @@ type WebFetchRuntimeParams = {
|
||||
timeoutSeconds: number;
|
||||
cacheTtlMs: number;
|
||||
userAgent: string;
|
||||
headers?: Record<string, string>;
|
||||
readabilityEnabled: boolean;
|
||||
config?: OpenClawConfig;
|
||||
useTrustedEnvProxy: boolean;
|
||||
@@ -611,8 +705,22 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
throw new Error("Invalid URL: must be http or https");
|
||||
}
|
||||
const headersCacheKey = resolveFetchHeadersCacheKey(params.headers);
|
||||
// Append the operator header set after the existing cache discriminators so
|
||||
// requests without custom headers keep their current cache key.
|
||||
const cacheDiscriminators = [
|
||||
`user-agent:${sha256Hex(params.userAgent)}`,
|
||||
params.providerCacheKey ? `provider:${params.providerCacheKey}` : "",
|
||||
allowRfc2544BenchmarkRange ? "allow-rfc2544" : "",
|
||||
allowIpv6UniqueLocalRange ? "allow-ipv6-ula" : "",
|
||||
useTrustedEnvProxy ? "trusted-env-proxy" : "",
|
||||
headersCacheKey ? `headers:${headersCacheKey}` : "",
|
||||
].filter(Boolean);
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`fetch:${parsedUrl.href}:${params.extractMode}:${params.maxChars}:user-agent:${sha256Hex(params.userAgent)}${params.providerCacheKey ? `:provider:${params.providerCacheKey}` : ""}${allowRfc2544BenchmarkRange ? ":allow-rfc2544" : ""}${allowIpv6UniqueLocalRange ? ":allow-ipv6-ula" : ""}${useTrustedEnvProxy ? ":trusted-env-proxy" : ""}`,
|
||||
[
|
||||
`fetch:${parsedUrl.href}:${params.extractMode}:${params.maxChars}`,
|
||||
...cacheDiscriminators,
|
||||
].join(":"),
|
||||
);
|
||||
const cached = readCache(FETCH_CACHE, cacheKey);
|
||||
if (cached) {
|
||||
@@ -633,12 +741,14 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string
|
||||
lookupFn: params.lookupFn,
|
||||
useEnvProxy: useTrustedEnvProxy,
|
||||
policy: ssrfPolicy,
|
||||
capture: params.headers
|
||||
? { sensitiveRequestHeaderNames: Object.keys(params.headers) }
|
||||
: undefined,
|
||||
init: {
|
||||
headers: {
|
||||
Accept: "text/markdown, text/html;q=0.9, */*;q=0.1",
|
||||
"User-Agent": params.userAgent,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
headers: buildWebFetchRequestHeaders({
|
||||
userAgent: params.userAgent,
|
||||
operatorHeaders: params.headers,
|
||||
}),
|
||||
},
|
||||
});
|
||||
res = result.response;
|
||||
@@ -926,6 +1036,7 @@ export function createWebFetchTool(options?: {
|
||||
),
|
||||
cacheTtlMs: resolveCacheTtlMs(executionFetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||
userAgent,
|
||||
headers: resolveFetchHeaders(executionFetch),
|
||||
readabilityEnabled,
|
||||
config,
|
||||
useTrustedEnvProxy: resolveFetchUseTrustedEnvProxy(executionFetch),
|
||||
|
||||
@@ -564,6 +564,7 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`]
|
||||
"tools.profile",
|
||||
"tools.sessions.visibility",
|
||||
"tools.web.fetch.enabled",
|
||||
"tools.web.fetch.headers.*",
|
||||
"tools.web.fetch.provider",
|
||||
"tools.web.fetch.readability",
|
||||
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange",
|
||||
|
||||
@@ -91,4 +91,33 @@ describe("realredactConfigSnapshot_real", () => {
|
||||
const activities = discord.activities as Record<string, unknown>;
|
||||
expect(activities.clientSecret).toBe(REDACTED_SENTINEL);
|
||||
});
|
||||
|
||||
it("redacts and restores web fetch operator headers from generated schema hints", () => {
|
||||
const hints = buildConfigSchema().uiHints;
|
||||
expect(hints["tools.web.fetch.headers.*"]?.sensitive).toBe(true);
|
||||
const snapshot = makeSnapshot({
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
headers: {
|
||||
"X-Routing-Target": "staging-private-route",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = redactConfigSnapshot(snapshot, hints);
|
||||
const tools = expectDefined(result.config.tools, "result.config.tools test invariant");
|
||||
const web = expectDefined(tools.web, "result.config.tools.web test invariant");
|
||||
const fetch = expectDefined(web.fetch, "result.config.tools.web.fetch test invariant");
|
||||
const headers = expectDefined(
|
||||
fetch.headers,
|
||||
"result.config.tools.web.fetch.headers test invariant",
|
||||
);
|
||||
expect(headers["X-Routing-Target"]).toBe(REDACTED_SENTINEL);
|
||||
|
||||
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
|
||||
expect(restored.tools.web.fetch.headers["X-Routing-Target"]).toBe("staging-private-route");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -542,6 +542,8 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
|
||||
"tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.",
|
||||
"tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).",
|
||||
"tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.",
|
||||
"tools.web.fetch.headers":
|
||||
"Extra request headers sent with direct web_fetch requests, for example gateway routing or authentication headers. Every configured value is treated as sensitive and redacted from exposed config and debug captures. Values are plain strings, support ${VAR} substitution and the global $${VAR} literal escape, and are sent to model-chosen URLs. Entries are validated when the request is built rather than at config load, so a bad name or unsendable value is dropped and logged instead of disabling the surface; Accept, Accept-Language, User-Agent, and framing headers such as Transfer-Encoding are dropped too. Use tools.web.fetch.userAgent to change the user agent. Cross-origin redirects retain only the guarded-fetch safe header allowlist, and changing the headers actually sent partitions the fetch cache.",
|
||||
"tools.web.fetch.readability":
|
||||
"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
|
||||
"tools.web.fetch.useTrustedEnvProxy":
|
||||
|
||||
@@ -323,6 +323,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
|
||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||
"tools.web.fetch.headers": "Web Fetch Request Headers",
|
||||
"tools.web.fetch.readability": "Web Fetch Readability Extraction",
|
||||
"tools.web.fetch.useTrustedEnvProxy": "Web Fetch Trusted Env Proxy",
|
||||
"tools.web.fetch.ssrfPolicy": "Web Fetch SSRF Policy",
|
||||
|
||||
@@ -703,6 +703,44 @@ describe("config schema", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts plain web fetch header strings but rejects non-string and SecretRef values", () => {
|
||||
const parsed = ToolsSchema.parse({
|
||||
web: {
|
||||
fetch: {
|
||||
headers: {
|
||||
"X-Routing-Target": "staging",
|
||||
"X-Presence-Flag": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.web?.fetch?.headers).toEqual({
|
||||
"X-Routing-Target": "staging",
|
||||
"X-Presence-Flag": "",
|
||||
});
|
||||
expect(
|
||||
ToolsSchema.safeParse({
|
||||
web: { fetch: { headers: { "X-Routing-Target": 42 } } },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
ToolsSchema.safeParse({
|
||||
web: {
|
||||
fetch: {
|
||||
headers: {
|
||||
"X-Routing-Target": {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "WEB_FETCH_ROUTING_TARGET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps top-level subagent tools schema limited to tool policy", () => {
|
||||
expect(
|
||||
ToolsSchema.safeParse({
|
||||
|
||||
@@ -463,6 +463,12 @@ export type ToolsConfig = {
|
||||
maxRedirects?: number;
|
||||
/** Override User-Agent header for fetch requests. */
|
||||
userAgent?: string;
|
||||
/**
|
||||
* Extra request headers sent with direct web_fetch requests. Every value is
|
||||
* treated as sensitive in exposed config. Entries a request cannot carry are
|
||||
* dropped with a warning at request time.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
/** Use Readability to extract main content (default: true). */
|
||||
readability?: boolean;
|
||||
/** Route web_fetch through a trusted HTTP(S) env proxy and let the proxy resolve DNS. Enable only when that proxy enforces outbound policy. */
|
||||
|
||||
@@ -426,6 +426,10 @@ const ToolsWebFetchSchema = z
|
||||
cacheTtlMinutes: z.number().nonnegative().optional(),
|
||||
maxRedirects: z.number().int().nonnegative().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
// Values are registered sensitive so exposed config redacts them. Names are
|
||||
// validated at request time rather than here, because a fail-closed config
|
||||
// error over one header typo would disable the whole surface.
|
||||
headers: z.record(z.string(), z.string().register(sensitive)).optional(),
|
||||
readability: z.boolean().optional(),
|
||||
useTrustedEnvProxy: z.boolean().optional(),
|
||||
ssrfPolicy: z
|
||||
|
||||
@@ -71,6 +71,7 @@ export type GuardedFetchOptions = {
|
||||
| {
|
||||
flowId?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
sensitiveRequestHeaderNames?: readonly string[];
|
||||
};
|
||||
maxRedirects?: number;
|
||||
/**
|
||||
@@ -345,6 +346,9 @@ async function captureGuardedFetchExchange(params: {
|
||||
captureOrigin: "guarded-fetch",
|
||||
...(params.auditContext ? { auditContext: params.auditContext } : {}),
|
||||
...params.capture?.meta,
|
||||
...(params.capture?.sensitiveRequestHeaderNames
|
||||
? { sensitiveRequestHeaderNames: params.capture.sensitiveRequestHeaderNames }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,20 @@ describe("redactedCaptureHeaders", () => {
|
||||
expect(redacted?.["x-trace-note"]).not.toContain("super-secret-value");
|
||||
});
|
||||
|
||||
it("redacts caller-declared sensitive header names regardless of case", () => {
|
||||
const redacted = redactedCaptureHeaders(
|
||||
{
|
||||
"X-Routing-Target": "staging-private-route",
|
||||
Accept: "text/plain",
|
||||
},
|
||||
["x-routing-target"],
|
||||
);
|
||||
expect(redacted).toEqual({
|
||||
"X-Routing-Target": "[REDACTED]",
|
||||
Accept: "text/plain",
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens node's array-valued headers instead of dropping them", () => {
|
||||
// node:http exposes repeated headers as arrays; the standalone proxy feeds
|
||||
// those in directly.
|
||||
|
||||
@@ -47,10 +47,14 @@ function isSensitiveCaptureHeaderName(name: string): boolean {
|
||||
|
||||
export function redactedCaptureHeaders(
|
||||
headers: Headers | Record<string, string | string[] | undefined> | undefined,
|
||||
additionalSensitiveNames?: Iterable<string>,
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const additionalSensitive = new Set(
|
||||
[...(additionalSensitiveNames ?? [])].map((name) => name.trim().toLowerCase()),
|
||||
);
|
||||
const entries =
|
||||
headers instanceof Headers ? Array.from(headers.entries()) : Object.entries(headers);
|
||||
const redacted: Record<string, string> = {};
|
||||
@@ -59,7 +63,7 @@ export function redactedCaptureHeaders(
|
||||
// providers use many token/key naming variants. Names that pass the check
|
||||
// still run through value redaction so a registered secret pasted into an
|
||||
// innocuous header does not survive.
|
||||
if (isSensitiveCaptureHeaderName(name)) {
|
||||
if (additionalSensitive.has(name.trim().toLowerCase()) || isSensitiveCaptureHeaderName(name)) {
|
||||
redacted[name] = REDACTED_CAPTURE_HEADER_VALUE;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -171,8 +171,10 @@ describe("debug proxy runtime", () => {
|
||||
Cookie: "sid=session-token",
|
||||
"x-api-key": "provider-key",
|
||||
"content-type": "application/json",
|
||||
"X-Routing-Target": "staging-private-route",
|
||||
"x-safe": "visible",
|
||||
},
|
||||
meta: { sensitiveRequestHeaderNames: ["x-routing-target"] },
|
||||
response: new Response("{}", {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -195,6 +197,7 @@ describe("debug proxy runtime", () => {
|
||||
Cookie: "[REDACTED]",
|
||||
"x-api-key": "[REDACTED]",
|
||||
"content-type": "application/json",
|
||||
"X-Routing-Target": "[REDACTED]",
|
||||
"x-safe": "visible",
|
||||
});
|
||||
const response = events.find((event) => event.kind === "response");
|
||||
|
||||
@@ -486,7 +486,16 @@ export function captureHttpExchange(
|
||||
method: params.method,
|
||||
}),
|
||||
contentType: requestContentType,
|
||||
headersJson: runtime.safeJsonString(redactedCaptureHeaders(params.requestHeaders)),
|
||||
headersJson: runtime.safeJsonString(
|
||||
redactedCaptureHeaders(
|
||||
params.requestHeaders,
|
||||
Array.isArray(params.meta?.sensitiveRequestHeaderNames)
|
||||
? params.meta.sensitiveRequestHeaderNames.filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
metaJson: redactedCaptureJson(params.meta, runtime.safeJsonString),
|
||||
...requestPayload,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user