[N13a] web_fetch built-in with pinned-DNS SSRF checks; http_request no longer open to DNS rebinding - #277
Merged
Conversation
…nnects to the address it checked - src/security/privateAddress.ts: isPrivateAddress() (the broker's list plus 192.0.0.0/24, 198.18.0.0/15, NAT64 64:ff9b::/96 and 6to4 2002::/16, with IPv4-mapped unwrapping) and pinnedLookup(), a dns.lookup-compatible function for undici's connect.lookup that resolves once, refuses private addresses and hands the socket the address it checked. The credential broker uses the shared list. - web_fetch (webFetchTool / createWebFetchTool, 'web-fetch' in specs): GET one page, HTML to text, redirect/byte/char/time caps, host lists before DNS, allowPrivate, pinned connections on every hop. - Security: http_request checked one DNS answer and let fetch resolve the name again, so a rebinding name reached private addresses. It now connects through an undici Agent with the pinned lookup; the sandboxed path resolves and checks in-process and the sandboxed process connects to that address. New allowPrivate option. Regression test proves a public-then-loopback resolver no longer reaches the local server. - Docs: tools, configuration, installation and deployment (what a Worker can and cannot check); Worker runtime comment corrected; CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
…Hosts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # docs/tools.md # llms-full.txt
# Conflicts: # docs/tools.md # llms-full.txt
# Conflicts: # CHANGELOG.md
# Conflicts: # CHANGELOG.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 2, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #255
Adds the
web_fetchbuilt-in, and fixes a DNS-rebinding hole inhttp_request(scope extended by the orchestrator, because it is a security fix).Security:
http_requestand DNS rebindingThe finding was real.
isBlockedHost()resolved the name withdns.promises.lookupand checked the answer. Thenfetchresolved the name again to connect. A name that answered a public address to the check and127.0.0.1to the connection got through.docs/tools.md,docs/deployment.mdand the comment insrc/deploy/runtime.worker.tsall called the check rebinding-safe.Proof. The new test
http.test.ts> "N13a: connects to the address it checked, so a rebinding name cannot reach a private address" stubs both resolver entry points with one shared answer sequence: public203.0.113.10first,127.0.0.1after that. I ran this test against the unchangedhttp.tsbefore the fix and it failed:promise resolved "'internal'" instead of rejecting, meaning the request reached the loopback server. After the fix the server gets no hit.Fix.
Agentwhoseconnect.lookupis the new sharedpinnedLookup(). That lookup is the only resolution. It checks every address and gives the socket the address it checked. This holds for every redirect hop.sandboxExecute, which is what agents use): the host is resolved and checked in the agent's process. The sandboxed process then connects to that address (SandboxFetchRequest.pinnedAddress: node:http/https with a fixedlookup;Hostand SNI keep the name). Two tests cover it:.invalidhost whose check-time resolution is stubbed to 127.0.0.1 is only reachable through the pinned address.sandbox.run().Behavior changes (in the CHANGELOG under Security):
http_requestrequest, not at import.createHttpTool({ allowPrivate })option. The existing local-server tests needed it, because they had relied on the old check-then-resolve-again gap.Workers. There is no DNS hook on Workers, so neither tool exists in the Worker build.
docs/deployment.mdnow says what a Worker can check (the URL, IP-literal hosts) and what it cannot guarantee (where a host name connects).web_fetch(N13a)src/security/privateAddress.tscontains:isPrivateAddress(): the broker's list plus 192.0.0.0/24, 198.18.0.0/15, 64:ff9b::/96 and 2002::/16, with IPv4-mapped unwrapping. Anything that is not an IP counts as private.resolvePublicAddresses().pinnedLookup().SsrfBlockedError. Its message names the host only, never the addresses.src/tools/built-in/webFetch.ts(webFetchTool,createWebFetchTool, options as specified) andhtmlToText.ts.htmlToText.tsis a linear-scan converter: no backtracking regex over the document, and nothing is executed or followed.'web-fetch'is added toRESOLVABLE_BUILT_IN_TOOLS. The Worker tool list is unchanged.docs/tools.md(table rows and a paragraph with an options snippet; no new heading),docs/configuration.md(spec tools table row),docs/installation.md(undici note),docs/deployment.md(Worker paragraph). CHANGELOG has entries under Security and Added.npm run docs:llmswas run.One deviation from the ticket text: for unsupported content types the ticket said both
content: ''and "a note incontent".contentcarries the note ([web_fetch: content type image/png is not supported; ...]).Acceptance criteria
src/security/privateAddress.test.tscovers:0.0.0.0and[::]pinnedLookupthrowing, returning the checked address, and resolving exactly onceallowPrivatesrc/security/credentialBroker.test.tsis green on the shared list.src/tools/built-in/webFetch.test.tsruns against a localnode:httpserver. It covers:maxBytes,maxCharsand a 404 with its bodyftp:is refusedallowedHosts/blockedHostsare applied before DNS[::1]) and allowed withallowPrivate: ['localhost']127.0.0.1second; the server is never hit and there is exactly one resolutionsrc/spec/specToAgent.test.ts:tools: ['web-fetch']resolves.docs:llmswas run. Every check below passed.src/tools/built-in/webFetch.live.test.tsis written: record and replay, with the test-onlytransportreplaying the saved page. It skips because there is no cassette. The record attempt was refused by OpenRouter with HTTP 402: the account's credits are used up (/credits: total_credits 10, total_usage ~10.20), even though the key's own counter shows usage 0 and limit_remaining 10. Recording needs the account topped up, then:Live test spend: before 0, after 0 (key usage). The one attempt was refused with 402 before any tokens were billed.
Verification
Run on the branch after merging
origin/main(44538d5, M10a; the only conflict was CHANGELOG### Added, where both sides were kept), head 2134c82:The run before that merge (on a7b63c9) had one failure:
src/execution/guardrails.test.ts, the E9 timeout test, withEPERMon removing its temp fixture. BRIEF-2 lists it as flaky under load. It passed alone (23/23) and in the full run above.Rebinding test checked against the old code (finishing pass). I put
origin/main'shttp.tsback and ran only the rebinding test: it failed withpromise resolved "'internal'" instead of rejecting, so the request reached the loopback server. With the fix it passes. It now also assertsanswers === 1: the pinned lookup is the only resolution.openApiTools(merged in #283)Its generated tools still use plain
fetchwith no private-address check, anddocs/openapi-tools.mdstill says so. That stays true after this PR. Routing it throughpinnedLookupis not a small change. It needs public API decisions:http://localhoston purpose, and base URLs are often internal);fetchoption;fetchhelper.Follow-up: #291.
For the docs site (G9)
docs/tools.md"Built-in tools" (table rows plus three paragraphs and a snippet),docs/configuration.md"Tools a spec can reference" (one row),docs/deployment.mdcloudflare-workertools bullet (rewritten),docs/installation.md(requirements line and the undici paragraph). The Arabic translations of these paragraphs need updating.🤖 Generated with Claude Code