Imported from benpate/uri (
AGENTS.md). Install upstream withnpx skills add benpate/uri. Copyright stays with the author.
uri — Notes for AI Agents
IsPublicIP(public.go) andIsLocalHostname(localhost.go) differ by INPUT, not by range. For a literal IP they are exact complements:IsLocalHostnameparses the string and delegates toNotPublicIP, soblockedRangesin public.go is the single source of truth and the two can no longer drift.FuzzSSRFSafetypins that bidirectionally. They still are not interchangeable, becauseIsPublicIPtakes a resolvednet.IPwhileIsLocalHostnametakes a string that may be a name — and a name is only classified by its text, never resolved. For an SSRF check, always useIsPublicIPon the resolved IP at connection time (e.g. in anet.Dialer.Controlhook); a hostname string check cannot catch a name that resolves to a private address. benpate/remote is that caller:publicIPsresolves the host and dials only addressesIsPublicIPaccepts.IsLocalHostnameclassifies names by text, and the text list is broad. Beyondlocalhost,127.0.0.1and::1it covers the RFC 6761.localhostTLD (any*.localhost), FQDN trailing-dot forms, the/etc/hostsaliases (localhost.localdomain,ip6-localhost,ip6-loopback,loopback), and the.local,.internal, andhost.docker.internalsuffixes. It also callsNormalizeHostfirst, so it accepts a full URL,host:port, userinfo, and bracketed IPv6 without any pre-stripping at the call site.- The cloud-metadata endpoint
169.254.169.254is deliberately caught by the link-local case in bothIsLocalHostnameandIsPublicIP. It looks like an ordinary public-ish address but routes to instance credentials — the single most important SSRF target. Don't "simplify" the link-local branches away. IsPublicIPworks on the resolvednet.IP, not on a string — and that's the point. A string like0x7f.1or2130706433is loopback once parsed, but a naive string check won't see it. Resolve first, classify second.ValidateHostnameexempts IP addresses and local names before applying DNS rules.127.0.0.1andfriday.localare valid hostnames here even though they have no IANA TLD. Only after those exemptions does it enforce RFC 1035 label/length limits and require the final segment to be a real IANA TLD. If you change the ordering, you'll start rejecting valid loopback/local inputs.- The TLD list is embedded at build time and loaded once in
init().RefreshTLDscan re-fetch the live list from IANA at runtime, but it is best-effort and fails closed onto the list already in memory. Three guards make that true, and all three must stay: a non-200 status is rejected (an IANA 503 page would otherwise be parsed as the TLD list), the download is capped withio.LimitReader(…, 1<<20), and a response that parses to zero TLDs is rejected rather than published. That last one is why parsing (parseTLDs) is split from publishing (importTLDs) — publishing an empty map makes every public hostname fail validation process-wide, soRefreshTLDsinspects the parse result before the atomic swap. - Refresh
_iana.txtfromhttps://data.iana.org/TLD/tlds-alpha-by-domain.txtwhenever you touch this package. Emissary callsRefreshTLDsonly once per node at boot, so a node uses this embedded copy until that fetch lands, and for its whole life if IANA is unreachable then. A stale copy rejects every host under a newly delegated TLD with a 422 that reads as bad input.initloads it without the empty-list guard, so run the tests afterward:TestTLDfails on a botched download. Hostreturns an RFC 6454 origin, not a raw authority. Per §6.2 it omits the port when it is the scheme's default, sohttps://server.com:443andhttps://server.comboth serialize tohttps://server.com— andIsSameOrigin, which is built onHost, treats them as one origin. The trim is scheme-specific (:80for http,:443for https only), and safe against IPv6 becauseurl.URL.Hostalways brackets an IPv6 literal, putting the port at the tail. Federated peers do emit explicit default ports; without this they would read as cross-origin.SafeURLandIsSafeRedirectURLare one policy with two return shapes — both callparseSafeURL, which is the ONLY copy of the accept/reject rule.IsSafeRedirectURLreports it;SafeURLre-serializes the parsed value on top (percent-encoding attribute-breaking characters, plus the'thaturl.URL.Stringleaves alone). Never re-implement the check in either one.IsValidRedirectURLis the third caller and the strictest. The single permitted gap:SafeURL's""doubles as its rejection sentinel, so a safe value that re-serializes to""(only the empty string, now that//is refused outright) is indistinguishable from a rejection — harmless, since both are a blank href.FuzzSafeURLMatchesRedirectGuardpins all of this, including that anythingIsValidRedirectURLaccepts is also accepted byIsSafeRedirectURL.Protocolreturns a usable prefix or nothing at all. It returns""unless the value genuinely had ascheme://. Two shapes are rejected thaturl.Parsewill happily hand you a scheme for: no scheme at all (which used to yield a bare"://"), and an opaque URI, whereOpaque != ""means there was no//—example.com:8080/pathparses as schemeexample.com, andmailto:a@b.casmailto. NoteSchemedoes NOT share this rule: it still returnslocalhostforlocalhost:3000, soProtocol != Scheme + "://"in exactly those cases.- Paired
Is…/Not…predicates are intentional, not redundant.NotLocalURL,NotPublicIP,NotValidTLD, etc. exist so callers read naturally at the call site (if uri.NotPublicIP(ip)). EachNot…is a one-line negation of itsIs…twin — keep them in sync. - Every rejection from
ParseURL,ValidateURL, andValidateHostnameis a 422.derp.Wrapinherits the inner error's code, and a non-derp error (*url.Error, anidnaerror) reads as 500, so a bare wrap would answer bad user input with a server error. Each wrap of a third-party error passesderp.WithCode(http.StatusUnprocessableEntity);TestValidateURL_RejectionsAreValidationErrorspins it. PrependProtocoltellshost:portfrom a scheme by the port.url.Parsereadslocalhost:8080as the schemelocalhostwith an opaque payload, the same way it readsmailto:me@example.com.hasOpaqueSchemecounts a payload that starts with a digits-only port ashost:port, and anything else as a scheme of its own, which returns"". The protocol is guessed from the whole value, becauseIsLocalHostnamenormalizes every shape, whileurl.Parse(uri).Hostname()is empty for the schemeless input this function exists to handle.FuzzPrependProtocolWellFormedpins that the added protocol is alwaysGuessProtocolForHostname's.Hostnamelower-cases and strips, it does not validate. It will happily return garbage from garbage. Run the result throughValidateHostnameif the input is untrusted.
A redirect target is judged the way a BROWSER parses it, not the way net/url does
parseSafeURL is what IsSafeRedirectURL, SafeURL, and IsValidRedirectURL all ask, and it has to agree with the WHATWG URL parser, because a browser is what finally follows the value. Two divergences from net/url were live open-redirect bypasses, and both are now closed.
- A leading run of slashes opens an AUTHORITY, and a backslash is a slash. For an http(s) URL a browser reads
\as/, and it enters authority parsing as soon as the first two characters are any mix of the two, ignoring every slash after that. So/\evil.com,\\evil.com,\/evil.com,/\/evil.comand///evil.comall resolve tohttps://evil.com, whilenet/urlreports an emptyHostand a same-site path for every one of them.hasAuthorityPrefixtherefore tests the first two characters and nothing else: a backslash later in the path (/foo/\bar) stays on this host, and rejecting it would break ordinary paths. - Leading whitespace is stripped before parsing. A browser removes C0 controls and spaces from both ends first, so
" //evil.com"is off-site to a browser and a harmless relative path tonet/url.parseSafeURLtrims the same set before it parses. Interior tabs and newlines, which a browser also removes, still fail closed the old way, becauseurl.Parserejects the control byte. - A browser deletes tabs and newlines from ANYWHERE in a URL, and
Pathworks on the DECODED path. So/%09/evil.comreachessafeLocalPathas/\t/evil.com, gets past the leading-slash collapse, and then reads as//evil.com. Emissary puts the sign-innextthroughPathAndQueryintoHx-Redirect, so this is a live post-login open redirect unlesssafeLocalPathdeletestabOrNewlinebefore it collapses. The fuzz oracleisProtocolRelativedeletes the same set; without it the fuzzer cannot see this class at all.
authoritySlashes is the single copy of that character set, shared with safeLocalPath in path.go, which collapses exactly the same run for PathAndQuery. Keep both on the constant. TestIsSafeRedirectURL_AuthorityPrefixBypass pins every spelling above, and its twin pins the paths that must stay safe.
What did NOT change: an absolute off-site http(s) URL is still safe. All of this is about values that only LOOK same-site. One behavior did change deliberately: // is now refused rather than accepted as a blank href.
IsValidRedirectURL is the rule for a destination chosen by someone else, and its NAME is load-bearing
IsSafeRedirectURL accepts a relative path, because most callers redirect inside their own server. IsValidRedirectURL refuses one, because its callers forward to a URL named by a remote server, which has no business naming a path here — a hostile home server answering an Activity Intent could otherwise bounce a visitor into /signout. It is IsSafeRedirectURL plus "absolute, with a valid host". ValidateHostname exempts loopback, literal IPs, and .local, so a home server in local development still passes; the TLD rule means a made-up TLD does not, and neither does .example, so write tests against example.com.
The name matches CodeQL's barrier-guard pattern, and almost nothing else here does. The go/unvalidated-url-redirection query recognises a sanitizer only by callee name, matched against (?i)(is_?)?(local_?url|valid_?redir(ect)?)(ur[li])?. IsValidRedirectURL matches. IsSafeRedirectURL, IsValidURL, and NotValidRedirectURL do not. So a call site that has to satisfy the scanner must be written in the positive form, as if !uri.IsValidRedirectURL(target), and the Not… twin — which exists only for the package's paired-predicate convention — will not be credited. Renaming this function silently re-opens every alert it closes.
Known defects, reproduced and not yet fixed
PathandPathAndQueryreturn the DECODED path, so a percent-encoded delimiter comes back live. Both useparsedURL.Pathwhere they wantEscapedPath().PathAndQuery("/foo%3Fbar")returns/foo?bar, turning an encoded?into a real query delimiter, and%23into a fragment; raw spaces and raw UTF-8 come through too, in a value the doc comment calls suitable for an HTTPLocationheader. Blast radius of the fix is one pinned test. The decoded tab or newline that made this an open redirect is deleted insafeLocalPath(see above); what remains is a changed meaning, never an off-site hop.ValidateHostnameaccepts a full URL when, and only when, it names a local host.IsValidHostname("http://localhost/../etc")is TRUE whileIsValidHostname("https://example.com")is FALSE, because theIsLocalHostnameearly-out normalizes a URL down to a bare host and the public-domain path below it does not. Acceptance therefore depends on which half you land in, and the local half is the security-sensitive one.ParseURLis unaffected — it passes an already-bareparsed.Hostname().ValidateHostnameaccepts control and invisible characters (BUG-171). It converts withidna.ToASCII, the Punycode profile, which maps and validates nothing, so"\u0080.com","a\u200d.com"(a zero-width joiner), and a soft hyphen all pass.idna.Lookup.ToASCIIrejects them, but also changes what some inputs normalize to.IsValidTLDre-implementsValidateTLDinstead of calling it (BUG-172). Both copies look the TLD up as given against lower-cased keys, soIsValidTLD("COM")is FALSE;ValidateHostnamelower-cases first, so nothing in the package notices.SchemeandProtocoldisagree on opaque URLs.Scheme("http:8080")is"http"butProtocol("http:8080")is"".Protocolhas an explicitOpaque != ""rule andSchemehas none, though the README presents them as a pair.- Do not re-flag these as new:
IsValidIP4Address("::ffff:1.2.3.4") == falseis a deliberate text-form classification and drives no security decision here;trimDefaultPort's:80trim cannot eatexample.com:8080; theinit()panic on embed failure is programmer-error-only; andRefreshTLDstaking nocontext.Contextis a deliberate fire-and-forget with a 30s client timeout. Hostnameis a thin alias forNormalizeHost— keep it that way. It used to have its own cut-at-the-first-delimiter logic, which left the querystring attached (example.com?q=1), left userinfo attached (evil.com@good.com), and truncated a bracketed IPv6 literal to"[". Callers build federated@user@hosthandles and referer checks out of this, so both functions must answer identically for every shape;TestHostname_MatchesNormalizeHostpins that. Note that an IPv6 result comes back unbracketed (::1), so it can contain colons.