fix(network): check where a public-only host name resolves, not only how it is spelled - #15064
Merged
diegosouzapw merged 3 commits intoSep 29, 2026
Conversation
…how it is spelled safeOutboundFetch with guard "public-only" rejected private hosts by looking at the URL's host name. A name is only a label: an attacker-owned name whose DNS answer is 127.0.0.1, a LAN address or the cloud metadata address passed that check and the request went there. The image and media downloads already resolve every answer and pin the connection; the other "public-only" callers (the built-in HTTP skill, the plugin marketplace, the favicon and model-discovery fetches) did not. Resolve the host when the guard is "public-only" and refuse it when any answer is not public. A lookup that fails is left to the request, which fails the same way. The built-in HTTP skill, whose URL comes from the caller's input, also asks for pinDns, so the connection goes to the address that was checked rather than to a second answer from a name that changes it between lookups. The pin is skipped for a request that goes through a proxy, which resolves the name itself, and for one that follows redirects, which leave the checked host. The "block-metadata" mode used for operator provider URLs is unchanged. The tests make names resolve to a real loopback listener, both for the guard's lookup and for the connection, and count what reaches it. Signed-off-by: Minxi Hou <houminxi@gmail.com>
…a pinned request cannot resolve Maintainer rework on top of the DNS check: - the lookup had no deadline of its own and ran before the request timeout, so a resolver that never answers held the request forever; it is now bounded by the request timeout (at most 5 s) and follows the caller's abort signal; - with pinDns, a failed lookup fell back to an unpinned connection that resolves the name again, which is exactly the answer the check exists to judge; the request is now refused (NETWORK_ERROR) when the connection was going to be pinned; - createPinnedFetch awaited dispatcher.close() before returning, and close() waits for the body the caller has not read yet, so any response over ~100 KB deadlocked until the timeout; the close is no longer awaited (the built-in HTTP skill now goes through this path); - the built-in HTTP skill test answers the lookup and stands in for the pinned connection through a test-only override (same pattern as diegosouzapw#13883); - the new test file no longer uses explicit any (lint error under tests/).
# Conflicts: # src/shared/network/dnsPinnedFetch.ts
diegosouzapw
merged commit Sep 29, 2026
0739f92
into
diegosouzapw:release/v3.8.51
11 of 16 checks passed
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.
Summary
safeOutboundFetch with guard "public-only" rejected private hosts by looking at
the URL's host name. A name is only a label: an attacker-owned name whose DNS
answer is 127.0.0.1, a LAN address or the cloud metadata address passed that
check and the request went there. The image and media downloads already resolve
every answer and pin the connection; the other "public-only" callers (the
built-in HTTP skill, the plugin marketplace, the favicon and model-discovery
fetches) did not.
Resolve the host when the guard is "public-only" and refuse it when any answer
is not public. A lookup that fails is left to the request, which fails the same
way. The built-in HTTP skill, whose URL comes from the caller's input, also asks
for the new
pinDnsoption, so the connection goes to the address that waschecked rather than to a second answer from a name that changes it between
lookups. The pin is skipped for a request that goes through a proxy, which
resolves the name itself, and for one that follows redirects, which leave the
checked host. The "block-metadata" mode used for operator provider URLs is
unchanged.
The tests make names resolve to a real loopback listener, both for the guard's
lookup and for the connection, and count what reaches it.
Related Issues
Validation
npm run typecheck:core,npm run check:cyclesnpm run linton the changed files (Prettier and ESLint clean)release/v3.8.51at5b230f1e5d); focused checks rerun afterwardEach new test was run against the change reverted (it fails) and restored (it passes).
Tests Added Or Updated
tests/unit/safe-outbound-fetch-dns-guard.test.tsCoverage Notes
The listed test exercises every changed production path: private, loopback, link-local and mixed answers, a public answer, a failed lookup, literals and the other guard modes (no lookup), and the pinned connection.
Reviewer Notes
Only
guard: "public-only"resolves the host;block-metadata(operator provider URLs) andnoneare unchanged, so a name that resolves to a metadata address is still not caught in that mode. A name that does not resolve is not blocked by the guard; the request fails on its own. WithoutpinDnsthe check and the connection are two separate lookups, so a resolver that changes its answer between them can still get through; only the HTTP skill asks for the pin so far, and other callers can opt in.Maintainer rework (merge-batch 2026-09-28 (release drain))
public-onlylookup had no deadline and ran before the request timeout, so a resolver that never answers held the request forever. It is now bounded by the request timeout (at most 5 s) and follows the caller's abort signal.pinDns, a failed lookup fell back to an unpinned connection that resolves the name again — the very answer the check exists to judge. When the connection was going to be pinned, a failed lookup now refuses the request (NETWORK_ERROR); unpinned requests keep the previous "let the request fail on its own" behaviour.skills-builtins-sandbox.test.tsmockedglobalThis.fetch, which the now-pinned HTTP skill no longer uses (it failed on the previous head and reached the real network). It now answers the lookup and stands in for the pinned connection through a test-only override (setSafeOutboundPinnedFetchTestOverride, same pattern as security: pinDns off at the three new public-only image fetch sites (DNS-rebinding TOCTOU) #13883).any(lint error undertests/).createPinnedFetchlarge-body fix already landed there (fix(sse): download Adobe Firefly and UC persona reference images with the guarded fetch #15045 +dns-pinned-fetch-large-body.test.ts), so the tip's version is kept.