Skip to content

inspect: the private-address guard is lexical, so a DNS name that resolves inward is fetched (the residual noted at probe-endpoint.ts:13-16) #13

Description

@bilguunbicktivism

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.endpointprobeEndpoint 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions