Summary
isPrivateHostname is documented in src/cli/commands/probe-endpoint.ts:13-16 as not resolving DNS:
"This guard does not resolve DNS, so names that resolve to private IPs (and DNS rebinding) require a resolve-and-check / pinned connection and are tracked as follow-up hardening."
This issue exists to make that "tracked as follow-up hardening" actually tracked — there is no issue, no PR, no advisory and no SECURITY.md entry for it, while the shipped CLI prints refusing to fetch and presents the guard to users as a working control. I am not reporting it as a new discovery; you wrote it down first. I am attaching a working demonstration and the reason I think the residual matters more on inspect than the comment's placement suggests.
Why the residual is larger here than for a self-directed probe
isPrivateHostname is defined in probe-endpoint.ts, but its consumer at inspect.ts:276 guards a value that is not the operator's: metadataURI comes from the ERC-8257 ToolRegistry at 0x265BB2DBFC0A8165C9A1941Eb1372F349baD2cf1, where registerTool is permissionless. And updateToolMetadata means an entry can be benign when it is reviewed and repointed afterwards.
Demonstration
Against @opensea/tool-sdk@0.28.5 installed from npm, with a local anvil standing in for the registry so nothing was written to any live chain:
metadataURI |
result |
http://localtest.me:8799/x |
fetched — a listener on 127.0.0.1:8799 recorded GET /x |
http://127.0.0.1:8799/x |
Error: metadata URI host "127.0.0.1" is a private/internal address; refusing to fetch |
http://169.254.169.254/x |
refused, same message |
https://example.com/x |
fetched normally |
localtest.me is a public name whose A record is 127.0.0.1. The first and second rows are the same host and the same port.
The guard is otherwise strong, which is worth saying: driven directly it blocked 14/14 alternate literal spellings (2130706433, 0177.0.0.1, 127.1, 0, [::ffff:127.0.0.1], 100.64.0.1, …) and allowed 4/4 genuinely public hosts, and 32 further payloads splicing format and ignorable codepoints (U+180E, U+00AD, U+200B–200D, U+2060, U+FEFF, U+3164, U+0149, fullwidth digits, U+3002/U+FF0E/U+FF61 dots) into the host were all either normalised and blocked or rejected by the URL parser. redirect: "manual" also correctly stops a redirect chain from reaching inward. A name is the one thing that gets through.
Scope of the primitive
One GET per inspect, to a host, port and path of the registrant's choosing, from inside the runner's network. The response does not reach the registrant. I tried to raise it to an attacker-chosen POST via manifest.endpoint → probeEndpoint and that does not hold: the manifest schema requires endpoint to use https, and a TLS handshake against an internal address will not present a certificate the registrant controls.
Suggested shape of the fix
Resolve, then decide, then connect to the address you decided about — an undici Agent with a filtering lookup passed as dispatcher, rather than a test on new URL(uri).hostname. isPrivateIpv4 / isPrivateIpv6 are already the right predicates; they need the resolved address rather than the typed string. That also closes rebinding, which a resolve-and-check alone does not.
Note on PR #12
#12 proposes reusing isPrivateHostname for metadataURI. Worth noting that shipped 0.28.5 is already ahead of it — the scheme check, the guard call and 169.254.0.0/16 are all present — and that applying #12 as written would not change the behaviour above, since it reuses the same lexical predicate.
Testing note
All local: a private anvil node, a stand-in registry contract, and listeners on 127.0.0.1. Nothing was written to the live ToolRegistry on any chain and no OpenSea service was contacted.
Summary
isPrivateHostnameis documented insrc/cli/commands/probe-endpoint.ts:13-16as not resolving DNS:This issue exists to make that "tracked as follow-up hardening" actually tracked — there is no issue, no PR, no advisory and no
SECURITY.mdentry for it, while the shipped CLI printsrefusing to fetchand presents the guard to users as a working control. I am not reporting it as a new discovery; you wrote it down first. I am attaching a working demonstration and the reason I think the residual matters more oninspectthan the comment's placement suggests.Why the residual is larger here than for a self-directed probe
isPrivateHostnameis defined inprobe-endpoint.ts, but its consumer atinspect.ts:276guards a value that is not the operator's:metadataURIcomes from the ERC-8257 ToolRegistry at0x265BB2DBFC0A8165C9A1941Eb1372F349baD2cf1, whereregisterToolis permissionless. AndupdateToolMetadatameans an entry can be benign when it is reviewed and repointed afterwards.Demonstration
Against
@opensea/tool-sdk@0.28.5installed from npm, with a local anvil standing in for the registry so nothing was written to any live chain:metadataURIhttp://localtest.me:8799/xGET /xhttp://127.0.0.1:8799/xError: metadata URI host "127.0.0.1" is a private/internal address; refusing to fetchhttp://169.254.169.254/xhttps://example.com/xlocaltest.meis a public name whose A record is127.0.0.1. The first and second rows are the same host and the same port.The guard is otherwise strong, which is worth saying: driven directly it blocked 14/14 alternate literal spellings (
2130706433,0177.0.0.1,127.1,0,[::ffff:127.0.0.1],100.64.0.1, …) and allowed 4/4 genuinely public hosts, and 32 further payloads splicing format and ignorable codepoints (U+180E, U+00AD, U+200B–200D, U+2060, U+FEFF, U+3164, U+0149, fullwidth digits, U+3002/U+FF0E/U+FF61 dots) into the host were all either normalised and blocked or rejected by the URL parser.redirect: "manual"also correctly stops a redirect chain from reaching inward. A name is the one thing that gets through.Scope of the primitive
One
GETperinspect, to a host, port and path of the registrant's choosing, from inside the runner's network. The response does not reach the registrant. I tried to raise it to an attacker-chosenPOSTviamanifest.endpoint→probeEndpointand that does not hold: the manifest schema requiresendpointto usehttps, and a TLS handshake against an internal address will not present a certificate the registrant controls.Suggested shape of the fix
Resolve, then decide, then connect to the address you decided about — an undici
Agentwith a filteringlookuppassed asdispatcher, rather than a test onnew URL(uri).hostname.isPrivateIpv4/isPrivateIpv6are already the right predicates; they need the resolved address rather than the typed string. That also closes rebinding, which a resolve-and-check alone does not.Note on PR #12
#12 proposes reusing
isPrivateHostnameformetadataURI. Worth noting that shipped 0.28.5 is already ahead of it — the scheme check, the guard call and169.254.0.0/16are all present — and that applying #12 as written would not change the behaviour above, since it reuses the same lexical predicate.Testing note
All local: a private anvil node, a stand-in registry contract, and listeners on 127.0.0.1. Nothing was written to the live ToolRegistry on any chain and no OpenSea service was contacted.