diff --git a/Dockerfile b/Dockerfile index d6281184301..1bd9054ea15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -180,10 +180,10 @@ RUN set -eu; \ # OpenClaw's web_fetch SSRF guard blocks *.internal hostnames before the # OpenShell L7 proxy sees the request. NemoClaw users legitimately reach # host-local approved services through host.openshell.internal after the -# OpenShell policy explicitly allows that host:port. Allow only this exact -# hostname, only inside an OpenShell sandbox, and only at the hostname-only -# check used by trusted env-proxy mode. Direct DNS-pinned/private-IP paths -# remain blocked, and metadata/link-local/private IP literals are unchanged. +# OpenShell policy explicitly allows that host:port. Add this exact hostname +# only to the web_fetch trusted-env-proxy policy, only inside an OpenShell +# sandbox. The generic SSRF helper and strict/direct DNS-pinned paths remain +# unmodified, so metadata/link-local/private IP literals are unchanged. # # === Removal criteria === # Patch 1: drop when OpenClaw deprecates withStrictGuardedFetchMode or @@ -191,9 +191,9 @@ RUN set -eu; \ # Patch 2: drop when OpenClaw fixes assertExplicitProxyAllowed to skip the # target hostname allowlist for the proxy hostname check (or exposes config # to disable the check). -# Patch 2b: drop when OpenClaw ships a reviewed host-gateway SSRF policy -# surface that can allow host.openshell.internal without allowing broader -# private/special-use hostnames. +# Patch 2b: drop when OpenClaw ships a reviewed web_fetch trusted-proxy SSRF +# policy surface that can allow host.openshell.internal without allowing +# broader private/special-use hostnames. # # SYNC WITH OPENCLAW: these patches classify the compiled OpenClaw dist at # build time. They apply the legacy patch when the old target exists, skip @@ -278,31 +278,37 @@ RUN set -eu; \ patch_fail "Patch 2 cannot safely skip"; \ fi; \ fi; \ - # --- Patch 2b: allow OpenShell host gateway hostname in trusted proxy mode --- \ - ssrf_hostname_files="$(grep -RIlE --include='*.js' 'function assertHostnameAllowedWithPolicy\(hostname, policy\)' "$OC_DIST" || true)"; \ - if [ -n "$ssrf_hostname_files" ]; then \ + # --- Patch 2b: allow OpenShell host gateway only through web_fetch trusted env proxy --- \ + # Reviewed against openclaw@2026.5.22 dist: fetchWithWebToolsNetworkGuard \ + # passes useEnvProxy into withTrustedEnvProxyGuardedFetchMode(resolved), and \ + # the SSRF guard consumes policy.allowedHostnames to skip private-network \ + # checks for an exact normalized hostname. hostnameAllowlist only gates \ + # hostname pattern matching and does not bypass .internal/private blocking. \ + web_guard_files="$(grep -RIlE --include='*.js' 'function fetchWithWebToolsNetworkGuard\(params\)' "$OC_DIST" || true)"; \ + if [ -n "$web_guard_files" ]; then \ patched_host_gateway=0; \ - for f in $ssrf_hostname_files; do \ - if grep -q 'nemoclaw: OpenShell host gateway' "$f"; then \ + for f in $web_guard_files; do \ + if grep -q 'nemoclaw: OpenShell host gateway for web_fetch trusted env proxy' "$f"; then \ echo "INFO: Patch 2b already present in $f"; \ else \ - grep -q 'normalizeHostname' "$f" || patch_fail "Patch 2b target $f is missing normalizeHostname"; \ - sed -i -E 's|(function assertHostnameAllowedWithPolicy\(hostname, policy\) \{)|\1 const normalizedHost = normalizeHostname(hostname); if (process.env.OPENSHELL_SANDBOX === "1" \&\& normalizedHost === "host.openshell.internal") return normalizedHost; /* nemoclaw: OpenShell host gateway via trusted proxy, see Dockerfile */ |' "$f"; \ - grep -Eq 'assertHostnameAllowedWithPolicy\(hostname, policy\) \{ const normalizedHost = normalizeHostname\(hostname\); if \(process\.env\.OPENSHELL_SANDBOX === "1" && normalizedHost === "host\.openshell\.internal"\) return normalizedHost; /\* nemoclaw: OpenShell host gateway' "$f" \ + grep -q 'withTrustedEnvProxyGuardedFetchMode(resolved)' "$f" \ + || patch_fail "Patch 2b target $f is missing reviewed trusted env-proxy web_fetch call"; \ + sed -i -E 's|return fetchWithSsrFGuard\(useEnvProxy \? withTrustedEnvProxyGuardedFetchMode\(resolved\) : withStrictGuardedFetchMode\(resolved\)\);|const hostGatewayPolicy = process.env.OPENSHELL_SANDBOX === "1" \&\& useEnvProxy \&\& new URL(resolved.url).hostname === "host.openshell.internal" ? { ...resolved.policy, allowedHostnames: [...resolved.policy?.allowedHostnames ?? [], "host.openshell.internal"] } : resolved.policy; return fetchWithSsrFGuard(useEnvProxy ? withTrustedEnvProxyGuardedFetchMode({ ...resolved, policy: hostGatewayPolicy }) : withStrictGuardedFetchMode(resolved)); /* nemoclaw: OpenShell host gateway for web_fetch trusted env proxy, see Dockerfile */|' "$f"; \ + grep -Fq 'process.env.OPENSHELL_SANDBOX === "1" && useEnvProxy && new URL(resolved.url).hostname === "host.openshell.internal"' "$f" \ || patch_fail "Patch 2b verification failed for $f"; \ patched_host_gateway=1; \ fi; \ done; \ if [ "$patched_host_gateway" = "1" ]; then \ - echo "INFO: Patch 2b applied to OpenClaw ${OC_VERSION} host-gateway hostname validator"; \ + echo "INFO: Patch 2b applied to OpenClaw ${OC_VERSION} web_fetch trusted-proxy host-gateway policy"; \ fi; \ else \ - internal_hostname_blocks="$(grep -RIlE --include='*.js' '\.internal|Blocked hostname or private/internal/special-use IP address|assertHostnameAllowedWithPolicy' "$OC_DIST" || true)"; \ - if [ -z "$internal_hostname_blocks" ]; then \ - echo "INFO: OpenClaw ${OC_VERSION} has no host-gateway hostname validator; Patch 2b not needed"; \ + web_fetch_proxy_refs="$(grep -RIlE --include='*.js' 'web_fetch|useEnvProxy|useTrustedEnvProxy|withTrustedEnvProxyGuardedFetchMode\(resolved\)' "$OC_DIST" || true)"; \ + if [ -z "$web_fetch_proxy_refs" ]; then \ + echo "INFO: OpenClaw ${OC_VERSION} has no web_fetch trusted env-proxy callsite; Patch 2b not needed"; \ else \ - echo "ERROR: Patch 2b target missing but internal-hostname SSRF blocks remain:" >&2; \ - printf '%s\n' "$internal_hostname_blocks" | head -n 5 >&2; \ + echo "ERROR: Patch 2b target missing but web_fetch/trusted-proxy references remain:" >&2; \ + printf '%s\n' "$web_fetch_proxy_refs" | head -n 5 >&2; \ patch_fail "Patch 2b cannot safely skip"; \ fi; \ fi; \ diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 1c8270bd09c..323f2342e5d 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -813,6 +813,11 @@ def _placeholder(channel: str, env_key: str) -> str: "loopbackMode": "proxy", } + # Keep keyless web_fetch available by default, but force it through the + # trusted env proxy. OpenShell's L7 policy remains the egress authority: + # without an approved host:port, the proxy denies the request. Remove this + # default only if OpenClaw gains a first-class least-privilege web_fetch + # policy that can preserve host-gateway fetch without bypassing OpenShell. tools_web = config.setdefault("tools", {}).setdefault("web", {}) tools_web["fetch"] = {"enabled": True, "useTrustedEnvProxy": True} diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index 973be1685d5..6102253c58c 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -18,6 +18,72 @@ const REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSIONS = [ const CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION = "2026.5.22"; const EXPECTED_OPENCLAW_INTEGRITY = "sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow=="; +const REVIEWED_OPENCLAW_2026_5_22_WEB_FETCH_SHAPE = [ + "async function fetchWithWebToolsNetworkGuard(params) {", + " const { timeoutSeconds, useEnvProxy, ...rest } = params;", + " const resolved = {", + " ...rest,", + " timeoutMs: resolveTimeoutMs({", + " timeoutMs: rest.timeoutMs,", + " timeoutSeconds", + " })", + " };", + " return fetchWithSsrFGuard(useEnvProxy ? withTrustedEnvProxyGuardedFetchMode(resolved) : withStrictGuardedFetchMode(resolved));", + "}", +].join("\n"); +const REVIEWED_OPENCLAW_2026_5_22_SSRF_POLICY_SHAPE = [ + "function shouldSkipPrivateNetworkChecks(hostname, policy) {", + " return isPrivateNetworkAllowedByPolicy(policy) || normalizeHostnameSet(policy?.allowedHostnames).has(hostname);", + "}", + "function resolveHostnamePolicyChecks(hostname, policy) {", + " const normalized = normalizeHostname(hostname);", + ' if (!normalized) throw new Error("Invalid hostname");', + " const hostnameAllowlist = normalizeHostnameAllowlist(policy?.hostnameAllowlist);", + " const skipPrivateNetworkChecks = shouldSkipPrivateNetworkChecks(normalized, policy);", + ' if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);', + " if (!skipPrivateNetworkChecks) assertAllowedHostOrIpOrThrow(normalized, policy);", + " return {", + " normalized,", + " skipPrivateNetworkChecks", + " };", + "}", +].join("\n"); + +function loadReviewedOpenClaw20260522SsrfPolicyShape() { + return new Function(` +class SsrFBlockedError extends Error {} +function normalizeHostname(value) { + return String(value || "").toLowerCase().replace(/\\.+$/, ""); +} +function normalizeHostnameSet(values) { + if (!values || values.length === 0) return new Set(); + return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean)); +} +function normalizeHostnameAllowlist(values) { + if (!values || values.length === 0) return []; + return Array.from(new Set(values.map((value) => normalizeHostname(value)).filter((value) => value !== "*" && value !== "*." && value.length > 0))); +} +function isPrivateNetworkAllowedByPolicy(policy) { + return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; +} +function matchesHostnameAllowlist(hostname, allowlist) { + return allowlist.length === 0 || allowlist.includes(hostname); +} +function assertAllowedHostOrIpOrThrow(hostnameOrIp) { + if (hostnameOrIp === "host.openshell.internal" || hostnameOrIp.endsWith(".internal") || hostnameOrIp === "10.0.0.1") { + throw new SsrFBlockedError("blocked " + hostnameOrIp); + } +} +${REVIEWED_OPENCLAW_2026_5_22_SSRF_POLICY_SHAPE} +return { shouldSkipPrivateNetworkChecks, resolveHostnamePolicyChecks }; + `)() as { + shouldSkipPrivateNetworkChecks: (hostname: string, policy?: Record) => boolean; + resolveHostnamePolicyChecks: ( + hostname: string, + policy?: Record, + ) => { normalized: string; skipPrivateNetworkChecks: boolean }; + }; +} function readRequiredMatch(file: string, pattern: RegExp, description: string): string { const match = fs.readFileSync(file, "utf-8").match(pattern); @@ -214,7 +280,80 @@ function runFetchGuardPatchBlock(dist: string, tmp: string, version = "2026.5.22 ); } +function webGuardedFetchFixtureSource(): string { + return [ + "const withStrictGuardedFetchMode = (params) => ({ ...params, mode: 'strict' });", + "const withTrustedEnvProxyGuardedFetchMode = (params) => ({ ...params, mode: 'trusted_env_proxy' });", + "globalThis.hostnameChecks = [];", + "function normalizeHostname(value) { return String(value || '').toLowerCase().replace(/\\.+$/, ''); }", + "function resolveHostnamePolicyChecks(hostname, policy) {", + " const normalized = normalizeHostname(hostname);", + " globalThis.hostnameChecks.push({ normalized, policy });", + " const allowedHostnames = new Set((policy?.allowedHostnames ?? []).map(normalizeHostname));", + " if (normalized === 'host.openshell.internal' && allowedHostnames.has(normalized)) return { normalized, skipPrivateNetworkChecks: true };", + " if (normalized === 'host.openshell.internal' || normalized.endsWith('.internal') || normalized === '169.254.169.254' || normalized === '10.0.0.1') throw new Error('blocked ' + normalized);", + " return { normalized, skipPrivateNetworkChecks: false };", + "}", + "function assertHostnameAllowedWithPolicy(hostname, policy) { return resolveHostnamePolicyChecks(hostname, policy).normalized; }", + "async function resolvePinnedHostnameWithPolicy(hostname, params = {}) { return { hostname: resolveHostnamePolicyChecks(hostname, params.policy).normalized }; }", + "async function fetchWithSsrFGuard(params) {", + " const parsed = new URL(params.url);", + " if (params.mode === 'trusted_env_proxy') return { hostname: assertHostnameAllowedWithPolicy(parsed.hostname, params.policy), mode: params.mode, policy: params.policy };", + " return { hostname: (await resolvePinnedHostnameWithPolicy(parsed.hostname, { policy: params.policy })).hostname, mode: params.mode, policy: params.policy };", + "}", + "async function fetchWithWebToolsNetworkGuard(params) {", + " const { timeoutSeconds, useEnvProxy, ...rest } = params;", + " const resolved = { ...rest, timeoutMs: rest.timeoutMs ?? timeoutSeconds * 1000 };", + " return fetchWithSsrFGuard(useEnvProxy ? withTrustedEnvProxyGuardedFetchMode(resolved) : withStrictGuardedFetchMode(resolved));", + "}", + "globalThis.assertHostnameAllowedWithPolicy = assertHostnameAllowedWithPolicy;", + "globalThis.fetchWithWebToolsNetworkGuard = fetchWithWebToolsNetworkGuard;", + "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b, fetchWithWebToolsNetworkGuard as c };", + "", + ].join("\n"); +} + describe("fetch-guard patch regression guard", () => { + it("anchors web_fetch host-gateway policy to the reviewed OpenClaw 2026.5.22 SSRF contract", () => { + expect(REVIEWED_OPENCLAW_2026_5_22_WEB_FETCH_SHAPE).toContain( + "function fetchWithWebToolsNetworkGuard(params)", + ); + expect(REVIEWED_OPENCLAW_2026_5_22_WEB_FETCH_SHAPE).toContain( + "withTrustedEnvProxyGuardedFetchMode(resolved)", + ); + expect(REVIEWED_OPENCLAW_2026_5_22_SSRF_POLICY_SHAPE).toContain( + "normalizeHostnameSet(policy?.allowedHostnames).has(hostname)", + ); + expect(REVIEWED_OPENCLAW_2026_5_22_SSRF_POLICY_SHAPE).toContain( + "normalizeHostnameAllowlist(policy?.hostnameAllowlist)", + ); + + const reviewed = loadReviewedOpenClaw20260522SsrfPolicyShape(); + expect( + reviewed.shouldSkipPrivateNetworkChecks("host.openshell.internal", { + allowedHostnames: ["HOST.OPENSHELL.INTERNAL."], + }), + ).toBe(true); + expect( + reviewed.shouldSkipPrivateNetworkChecks("host.openshell.internal", { + hostnameAllowlist: ["host.openshell.internal"], + }), + ).toBe(false); + expect( + reviewed.resolveHostnamePolicyChecks("host.openshell.internal", { + allowedHostnames: ["host.openshell.internal"], + }), + ).toEqual({ + normalized: "host.openshell.internal", + skipPrivateNetworkChecks: true, + }); + expect(() => + reviewed.resolveHostnamePolicyChecks("host.openshell.internal", { + hostnameAllowlist: ["host.openshell.internal"], + }), + ).toThrow(/blocked host\.openshell\.internal/); + }); + it("fails the image build when the NemoClaw OpenClaw plugin cannot install", () => { const command = dockerRunCommandBetween( "# Install NemoClaw plugin into OpenClaw", @@ -288,6 +427,7 @@ describe("fetch-guard patch regression guard", () => { fs.symlinkSync(symlinkTarget, symlinkBase); const fetchGuardPath = path.join(dist, "fetch-guard-fixture.js"); + const webGuardPath = path.join(dist, "web-guarded-fetch-fixture.js"); const installSafePath = path.join(dist, "install-safe-path-fixture.js"); const installPackageDirPath = path.join(dist, "install-package-dir-fixture.js"); const clientPath = path.join(dist, "client-fixture.js"); @@ -315,6 +455,7 @@ describe("fetch-guard patch regression guard", () => { "", ].join("\n"), ); + fs.writeFileSync(webGuardPath, webGuardedFetchFixtureSource()); fs.writeFileSync( installSafePath, [ @@ -360,15 +501,44 @@ describe("fetch-guard patch regression guard", () => { process.env.OPENSHELL_SANDBOX = "1"; try { await (globalThis as any).assertExplicitProxyAllowed("http://10.200.0.1:3128"); - expect((globalThis as any).assertHostnameAllowedWithPolicy("host.openshell.internal")).toBe( - "host.openshell.internal", - ); - expect(() => (globalThis as any).assertHostnameAllowedWithPolicy("foo.internal")).toThrow( - /blocked foo\.internal/, + await import(`${webGuardPath}?${Date.now()}`); + const trusted = await (globalThis as any).fetchWithWebToolsNetworkGuard({ + url: "http://host.openshell.internal:8000", + useEnvProxy: true, + }); + expect(trusted.hostname).toBe("host.openshell.internal"); + expect(trusted.policy).toEqual({ + allowedHostnames: ["host.openshell.internal"], + }); + expect(() => (globalThis as any).assertHostnameAllowedWithPolicy("host.openshell.internal")).toThrow( + /blocked host\.openshell\.internal/, ); - expect(() => - (globalThis as any).assertHostnameAllowedWithPolicy("169.254.169.254"), - ).toThrow(/blocked 169\.254\.169\.254/); + delete process.env.OPENSHELL_SANDBOX; + await expect( + (globalThis as any).fetchWithWebToolsNetworkGuard({ + url: "http://host.openshell.internal:8000", + useEnvProxy: true, + }), + ).rejects.toThrow(/blocked host\.openshell\.internal/); + process.env.OPENSHELL_SANDBOX = "1"; + await expect( + (globalThis as any).fetchWithWebToolsNetworkGuard({ + url: "http://host.openshell.internal:8000", + useEnvProxy: false, + }), + ).rejects.toThrow(/blocked host\.openshell\.internal/); + await expect( + (globalThis as any).fetchWithWebToolsNetworkGuard({ + url: "http://foo.internal", + useEnvProxy: true, + }), + ).rejects.toThrow(/blocked foo\.internal/); + await expect( + (globalThis as any).fetchWithWebToolsNetworkGuard({ + url: "http://169.254.169.254", + useEnvProxy: true, + }), + ).rejects.toThrow(/blocked 169\.254\.169\.254/); } finally { if (previousSandboxEnv === undefined) { delete process.env.OPENSHELL_SANDBOX; @@ -377,7 +547,17 @@ describe("fetch-guard patch regression guard", () => { } } expect((globalThis as any).proxyChecks).toEqual([]); - expect((globalThis as any).hostnameChecks).toEqual(["foo.internal", "169.254.169.254"]); + expect((globalThis as any).hostnameChecks).toEqual([ + { + normalized: "host.openshell.internal", + policy: { allowedHostnames: ["host.openshell.internal"] }, + }, + { normalized: "host.openshell.internal", policy: undefined }, + { normalized: "host.openshell.internal", policy: undefined }, + { normalized: "host.openshell.internal", policy: undefined }, + { normalized: "foo.internal", policy: undefined }, + { normalized: "169.254.169.254", policy: undefined }, + ]); const installSafe = await import(`${installSafePath}?${Date.now()}`); await expect(installSafe.acceptsBaseDir(symlinkBase)).resolves.toBe(true); @@ -405,6 +585,7 @@ describe("fetch-guard patch regression guard", () => { fs.mkdirSync(dist, { recursive: true }); fs.writeFileSync(path.join(tmp, "package.json"), '{"type":"module"}\n'); const modulePath = path.join(dist, "fetch-guard-test.js"); + const webGuardPath = path.join(dist, "web-guarded-fetch-test.js"); fs.writeFileSync( modulePath, [ @@ -427,6 +608,7 @@ describe("fetch-guard patch regression guard", () => { "", ].join("\n"), ); + fs.writeFileSync(webGuardPath, webGuardedFetchFixtureSource()); try { const patch = runFetchGuardPatchBlock( @@ -444,12 +626,20 @@ describe("fetch-guard patch regression guard", () => { "--input-type=module", "-e", `const exports = await import(${JSON.stringify(modulePath)}); +const web = await import(${JSON.stringify(webGuardPath)}); if (exports.a !== exports.b) throw new Error('strict export was not redirected to trusted env proxy mode'); await globalThis.assertExplicitProxyAllowed('http://10.200.0.1:3128'); if (globalThis.proxyChecks.length !== 0) throw new Error('sandbox proxy validation did not bypass target-policy checks'); -if (globalThis.assertHostnameAllowedWithPolicy('host.openshell.internal') !== 'host.openshell.internal') throw new Error('host gateway was not allowed'); +let genericBlocked = false; +try { globalThis.assertHostnameAllowedWithPolicy('host.openshell.internal'); } catch { genericBlocked = true; } +if (!genericBlocked) throw new Error('generic SSRF helper allowed host gateway'); +const trusted = await web.c({ url: 'http://host.openshell.internal:8000', useEnvProxy: true }); +if (trusted.hostname !== 'host.openshell.internal') throw new Error('host gateway was not allowed through web_fetch trusted proxy'); +let strictBlocked = false; +try { await web.c({ url: 'http://host.openshell.internal:8000', useEnvProxy: false }); } catch { strictBlocked = true; } +if (!strictBlocked) throw new Error('strict web_fetch allowed host gateway'); let blocked = false; -try { globalThis.assertHostnameAllowedWithPolicy('10.0.0.1'); } catch { blocked = true; } +try { await web.c({ url: 'http://10.0.0.1', useEnvProxy: true }); } catch { blocked = true; } if (!blocked) throw new Error('private IP literal was not blocked');`, ], { encoding: "utf-8", env: { ...process.env, OPENSHELL_SANDBOX: "1" }, timeout: 5000 }, @@ -680,7 +870,7 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, } }); - it("fails closed when the host-gateway hostname validator target disappears but internal-host blocks remain", () => { + it("fails closed when the web_fetch trusted-proxy callsite disappears but web fetch refs remain", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-host-gateway-unknown-")); const dist = path.join(tmp, "dist"); fs.mkdirSync(dist, { recursive: true }); @@ -688,12 +878,14 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, path.join(dist, "ssrf-host-gateway-unknown.js"), [ "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", - "function isBlockedHostnameNormalized(normalized) {", - " return normalized.endsWith('.internal');", + "const webFetchConfig = { useTrustedEnvProxy: true };", + "async function runWebFetch() {", + " return fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({ url: 'http://example.com' }));", "}", "async function fetchGuardedMediaResponse() {", - " return fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({}));", + " return fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({ url: 'http://example.com' }));", "}", + "const toolName = 'web_fetch';", "export { withTrustedEnvProxyGuardedFetchMode as a };", "", ].join("\n"), @@ -703,7 +895,40 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, const patch = runFetchGuardPatchBlock(dist, tmp, "2026.6.1"); expect(patch.status).toBe(1); expect(patch.stderr).toContain( - "Patch 2b target missing but internal-hostname SSRF blocks remain", + "Patch 2b target missing but web_fetch/trusted-proxy references remain", + ); + expect(patch.stderr).toContain("Patch 2b cannot safely skip"); + expect(patch.stderr).toContain("OpenClaw 2026.6.1"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("fails closed when the web_fetch target disappears but the runtime useEnvProxy symbol remains", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-use-env-proxy-unknown-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + fs.writeFileSync( + path.join(dist, "ssrf-host-gateway-use-env-proxy-unknown.js"), + [ + "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", + "async function fetchGuardedMediaResponse() {", + " return fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({ url: 'http://example.com' }));", + "}", + "async function renamedWebToolsNetworkGuard(params) {", + " const { useEnvProxy, ...rest } = params;", + " return useEnvProxy ? rest : { ...rest, strict: true };", + "}", + "export { renamedWebToolsNetworkGuard as t };", + "", + ].join("\n"), + ); + + try { + const patch = runFetchGuardPatchBlock(dist, tmp, "2026.6.1"); + expect(patch.status).toBe(1); + expect(patch.stderr).toContain( + "Patch 2b target missing but web_fetch/trusted-proxy references remain", ); expect(patch.stderr).toContain("Patch 2b cannot safely skip"); expect(patch.stderr).toContain("OpenClaw 2026.6.1");