diff --git a/aggregators/kagi-news/src/uri_sanitizer.py b/aggregators/kagi-news/src/uri_sanitizer.py index bf29a2e..d227e86 100644 --- a/aggregators/kagi-news/src/uri_sanitizer.py +++ b/aggregators/kagi-news/src/uri_sanitizer.py @@ -45,15 +45,13 @@ _MAX_URI_LENGTH = 8192 _SCHEME = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*):") _ATPROTO_SCHEME = re.compile(r"^[a-z][a-z.-]{0,80}$") -# Schemes refused outright rather than sanitized. These reach fields that -# clients render as clickable links, and sanitizing would otherwise happily -# repair one into a valid record. Two categories: -# - javascript/data/vbscript execute or inline content in the renderer, so a -# stored one is stored XSS in every client that does not defend itself. -# - file/mailto do not name a fetchable remote resource. file: points at the -# viewer's own disk, and mailto: in a public federated feed is an -# address-harvesting and spam vector rather than a link to content. -_FORBIDDEN_SCHEMES = frozenset({"javascript", "data", "vbscript", "file", "mailto"}) +# The only schemes sanitize_uri will emit. These reach fields that clients +# render as clickable links, and sanitizing would otherwise happily repair an +# unsafe URI into a valid record. An allowlist rather than a blocklist: a +# blocklist of javascript/data/vbscript/file/mailto still let through ftp:, +# blob:, intent: and every custom app scheme, none of which is a web link a +# browser should navigate a reader to from a feed. +_ALLOWED_SCHEMES = frozenset({"http", "https"}) def is_valid_uri(value: str) -> bool: @@ -112,19 +110,26 @@ def sanitize_uri(value) -> str: scheme = match.group(1).lower() if not _ATPROTO_SCHEME.match(scheme): raise ValueError(f"uri scheme is not valid for the atproto uri format: {scheme!r}") - if scheme in _FORBIDDEN_SCHEMES: - raise ValueError(f"uri scheme is not allowed in a rendered link: {scheme!r}") + if scheme not in _ALLOWED_SCHEMES: + raise ValueError( + f"uri scheme is not allowed in a rendered link (only http and https are accepted): {scheme!r}" + ) + + # An http(s) URI must be hierarchical with a host. "https:foo" and + # "https://" satisfy the atproto format and the scheme check, but the + # WHATWG parser every client renders through rejects them, so a record + # carrying one would have a link that silently vanishes in the UI. + rest = trimmed[match.end():] + if not rest.startswith("//"): + raise ValueError(f"uri has no host: {scheme} URI has no authority (expected {scheme}://host/…)") + authority, remainder = _split_authority(rest[2:]) + if not _host_of(authority): + raise ValueError(f"uri has no host: {scheme} URI has an empty host") if is_valid_uri(trimmed): return trimmed - rest = trimmed[match.end():] - if rest.startswith("//"): - authority, remainder = _split_authority(rest[2:]) - sanitized = f"{scheme}://{_encode_authority(authority)}{_escape_non_graph(remainder)}" - else: - # Opaque URI (urn:, magnet:, at: without an authority, …). - sanitized = f"{scheme}:{_escape_non_graph(rest)}" + sanitized = f"{scheme}://{_encode_authority(authority)}{_escape_non_graph(remainder)}" if len(sanitized) > _MAX_URI_LENGTH: raise ValueError( @@ -135,6 +140,18 @@ def sanitize_uri(value) -> str: return sanitized +def _host_of(authority: str) -> str: + """Return the host portion of an authority (userinfo and port stripped).""" + host = authority.rsplit("@", 1)[-1] + if host.startswith("["): + end = host.find("]") + return host[: end + 1] if end >= 0 else host + head, sep, tail = host.rpartition(":") + if sep and tail.isdigit(): + return head + return host + + def _split_authority(value: str) -> tuple: """Split the authority from the rest. Per RFC 3986 it ends at / ? or #.""" for index, char in enumerate(value): diff --git a/aggregators/kagi-news/tests/test_uri_sanitizer.py b/aggregators/kagi-news/tests/test_uri_sanitizer.py index 7b77afb..4f7dd16 100644 --- a/aggregators/kagi-news/tests/test_uri_sanitizer.py +++ b/aggregators/kagi-news/tests/test_uri_sanitizer.py @@ -28,6 +28,7 @@ _ERROR_CLASS_MESSAGES = { "no_scheme": "scheme", "bad_scheme": "not valid for the atproto uri format", "scheme_forbidden": "not allowed in a rendered link", + "no_authority": "no host", "too_long": "too long", "unnormalizable": "cannot", } diff --git a/aggregators/reddit-highlights/src/uri_sanitizer.py b/aggregators/reddit-highlights/src/uri_sanitizer.py index bf29a2e..d227e86 100644 --- a/aggregators/reddit-highlights/src/uri_sanitizer.py +++ b/aggregators/reddit-highlights/src/uri_sanitizer.py @@ -45,15 +45,13 @@ _MAX_URI_LENGTH = 8192 _SCHEME = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*):") _ATPROTO_SCHEME = re.compile(r"^[a-z][a-z.-]{0,80}$") -# Schemes refused outright rather than sanitized. These reach fields that -# clients render as clickable links, and sanitizing would otherwise happily -# repair one into a valid record. Two categories: -# - javascript/data/vbscript execute or inline content in the renderer, so a -# stored one is stored XSS in every client that does not defend itself. -# - file/mailto do not name a fetchable remote resource. file: points at the -# viewer's own disk, and mailto: in a public federated feed is an -# address-harvesting and spam vector rather than a link to content. -_FORBIDDEN_SCHEMES = frozenset({"javascript", "data", "vbscript", "file", "mailto"}) +# The only schemes sanitize_uri will emit. These reach fields that clients +# render as clickable links, and sanitizing would otherwise happily repair an +# unsafe URI into a valid record. An allowlist rather than a blocklist: a +# blocklist of javascript/data/vbscript/file/mailto still let through ftp:, +# blob:, intent: and every custom app scheme, none of which is a web link a +# browser should navigate a reader to from a feed. +_ALLOWED_SCHEMES = frozenset({"http", "https"}) def is_valid_uri(value: str) -> bool: @@ -112,19 +110,26 @@ def sanitize_uri(value) -> str: scheme = match.group(1).lower() if not _ATPROTO_SCHEME.match(scheme): raise ValueError(f"uri scheme is not valid for the atproto uri format: {scheme!r}") - if scheme in _FORBIDDEN_SCHEMES: - raise ValueError(f"uri scheme is not allowed in a rendered link: {scheme!r}") + if scheme not in _ALLOWED_SCHEMES: + raise ValueError( + f"uri scheme is not allowed in a rendered link (only http and https are accepted): {scheme!r}" + ) + + # An http(s) URI must be hierarchical with a host. "https:foo" and + # "https://" satisfy the atproto format and the scheme check, but the + # WHATWG parser every client renders through rejects them, so a record + # carrying one would have a link that silently vanishes in the UI. + rest = trimmed[match.end():] + if not rest.startswith("//"): + raise ValueError(f"uri has no host: {scheme} URI has no authority (expected {scheme}://host/…)") + authority, remainder = _split_authority(rest[2:]) + if not _host_of(authority): + raise ValueError(f"uri has no host: {scheme} URI has an empty host") if is_valid_uri(trimmed): return trimmed - rest = trimmed[match.end():] - if rest.startswith("//"): - authority, remainder = _split_authority(rest[2:]) - sanitized = f"{scheme}://{_encode_authority(authority)}{_escape_non_graph(remainder)}" - else: - # Opaque URI (urn:, magnet:, at: without an authority, …). - sanitized = f"{scheme}:{_escape_non_graph(rest)}" + sanitized = f"{scheme}://{_encode_authority(authority)}{_escape_non_graph(remainder)}" if len(sanitized) > _MAX_URI_LENGTH: raise ValueError( @@ -135,6 +140,18 @@ def sanitize_uri(value) -> str: return sanitized +def _host_of(authority: str) -> str: + """Return the host portion of an authority (userinfo and port stripped).""" + host = authority.rsplit("@", 1)[-1] + if host.startswith("["): + end = host.find("]") + return host[: end + 1] if end >= 0 else host + head, sep, tail = host.rpartition(":") + if sep and tail.isdigit(): + return head + return host + + def _split_authority(value: str) -> tuple: """Split the authority from the rest. Per RFC 3986 it ends at / ? or #.""" for index, char in enumerate(value): diff --git a/aggregators/reddit-highlights/tests/test_uri_sanitizer.py b/aggregators/reddit-highlights/tests/test_uri_sanitizer.py index 7b77afb..4f7dd16 100644 --- a/aggregators/reddit-highlights/tests/test_uri_sanitizer.py +++ b/aggregators/reddit-highlights/tests/test_uri_sanitizer.py @@ -28,6 +28,7 @@ _ERROR_CLASS_MESSAGES = { "no_scheme": "scheme", "bad_scheme": "not valid for the atproto uri format", "scheme_forbidden": "not allowed in a rendered link", + "no_authority": "no host", "too_long": "too long", "unnormalizable": "cannot", } diff --git a/internal/core/posts/embed_validation_test.go b/internal/core/posts/embed_validation_test.go index 6f84fa7..7f11c7b 100644 --- a/internal/core/posts/embed_validation_test.go +++ b/internal/core/posts/embed_validation_test.go @@ -510,6 +510,8 @@ func TestNormalizeEmbedURIsPreservesErrorChain(t *testing.T) { }{ {"no scheme", "example.com/path", validation.ErrURINoScheme, "embed.external.uri"}, {"forbidden scheme", "javascript:alert(1)", validation.ErrURISchemeNotAllowed, "embed.external.uri"}, + {"non-web scheme (allowlist)", "ftp://example.com/file.zip", validation.ErrURISchemeNotAllowed, "embed.external.uri"}, + {"blob scheme (allowlist)", "blob:https://example.com/abc", validation.ErrURISchemeNotAllowed, "embed.external.uri"}, {"bad scheme", "s3://bucket/key", validation.ErrURIBadScheme, "embed.external.uri"}, } for _, tt := range tests { @@ -533,26 +535,32 @@ func TestNormalizeEmbedURIsPreservesErrorChain(t *testing.T) { } // TestNormalizeEmbedURIsRejectsForbiddenSchemeInSources ensures the scheme guard -// applies to aggregated sources too, not just the primary link. +// applies to aggregated sources too, not just the primary link. ftp: is included +// because the old blocklist accepted it: it proves sources see the allowlist, +// not merely the executable-scheme refusal. func TestNormalizeEmbedURIsRejectsForbiddenSchemeInSources(t *testing.T) { - embed := map[string]interface{}{ - "$type": embedTypeExternal, - "external": map[string]interface{}{ - "uri": "https://kagi.com/news/daily", - "sources": []interface{}{ - map[string]interface{}{"uri": "https://example.com/ok"}, - map[string]interface{}{"uri": "javascript:alert(1)"}, - }, - }, - } - err := normalizeEmbedURIs(embed) - if err == nil { - t.Fatal("normalizeEmbedURIs() = nil, want the forbidden scheme rejected") - } - if !errors.Is(err, validation.ErrURISchemeNotAllowed) { - t.Errorf("error = %v, want ErrURISchemeNotAllowed", err) - } - if !strings.Contains(err.Error(), "sources[1]") { - t.Errorf("error = %q, want it to point at sources[1]", err) + for _, bad := range []string{"javascript:alert(1)", "ftp://example.com/file.zip"} { + t.Run(bad, func(t *testing.T) { + embed := map[string]interface{}{ + "$type": embedTypeExternal, + "external": map[string]interface{}{ + "uri": "https://kagi.com/news/daily", + "sources": []interface{}{ + map[string]interface{}{"uri": "https://example.com/ok"}, + map[string]interface{}{"uri": bad}, + }, + }, + } + err := normalizeEmbedURIs(embed) + if err == nil { + t.Fatal("normalizeEmbedURIs() = nil, want the forbidden scheme rejected") + } + if !errors.Is(err, validation.ErrURISchemeNotAllowed) { + t.Errorf("error = %v, want ErrURISchemeNotAllowed", err) + } + if !strings.Contains(err.Error(), "sources[1]") { + t.Errorf("error = %q, want it to point at sources[1]", err) + } + }) } } diff --git a/internal/core/richtext/facets_test.go b/internal/core/richtext/facets_test.go index cb4d4de..3c4cef9 100644 --- a/internal/core/richtext/facets_test.go +++ b/internal/core/richtext/facets_test.go @@ -710,6 +710,11 @@ func TestNormalizeLinkURIsRejectsForbiddenSchemes(t *testing.T) { "vbscript:msgbox(1)", "file:///etc/passwd", "mailto:someone@example.com", + // Allowlist, not blocklist: anything that is not http/https is refused. + "ftp://example.com/file.zip", + "blob:https://example.com/9d1b3b2a", + "intent://scan/#Intent;scheme=zxing;end", + "at://did:plc:abc/social.coves.community.post/xyz", } { t.Run(uri, func(t *testing.T) { facets := []interface{}{linkFacet(uri)} diff --git a/internal/validation/testdata/uri_vectors.json b/internal/validation/testdata/uri_vectors.json index 2a7f635..d392791 100644 --- a/internal/validation/testdata/uri_vectors.json +++ b/internal/validation/testdata/uri_vectors.json @@ -15,7 +15,8 @@ " empty -> ErrURIEmpty", " no_scheme -> ErrURINoScheme", " bad_scheme -> ErrURIBadScheme", - " scheme_forbidden -> ErrURISchemeNotAllowed", + " scheme_forbidden -> ErrURISchemeNotAllowed (any scheme other than http/https)", + " no_authority -> ErrURINoAuthority (http(s) without a //host part)", " too_long -> ErrURITooLong", " unnormalizable -> ErrURIUnnormalizable (includes punycode failures)" ], @@ -136,24 +137,39 @@ "output": "https://%C3%BCser@[2001:db8::1]:8080/p" }, { - "name": "at-uri with non-ascii rkey encodes without misreading the did as a port", - "input": "at://did:plc:abc123/social.coves.community.post/héllo", - "output": "at://did:plc:abc123/social.coves.community.post/h%C3%A9llo" + "name": "at-uri is refused in a link field even when it conforms (record refs use embed.post, not a link)", + "input": "at://did:plc:abc123/social.coves.community.post/3kabc", + "error": "scheme_forbidden" }, { - "name": "at-uri that already conforms", - "input": "at://did:plc:abc123/social.coves.community.post/3kabc", - "output": "at://did:plc:abc123/social.coves.community.post/3kabc" + "name": "http(s) without an authority is refused: browsers cannot parse it", + "input": "https:isbn:café-2024", + "error": "no_authority" + }, + { + "name": "http(s) with only a query and no authority is refused", + "input": "https:?xt=urn:btih:café&dn=naïve", + "error": "no_authority" + }, + { + "name": "scheme and slashes with nothing after is refused", + "input": "https://", + "error": "no_authority" }, { - "name": "opaque uri (no authority) encodes its body", - "input": "urn:isbn:café-2024", - "output": "urn:isbn:caf%C3%A9-2024" + "name": "empty host before a path is refused", + "input": "https:///path", + "error": "no_authority" }, { - "name": "opaque uri with query and fragment encodes throughout", - "input": "magnet:?xt=urn:btih:café&dn=naïve", - "output": "magnet:?xt=urn:btih:caf%C3%A9&dn=na%C3%AFve" + "name": "userinfo with no host is refused", + "input": "https://user:pw@/path", + "error": "no_authority" + }, + { + "name": "ipv6 literal host is accepted", + "input": "https://[2001:db8::1]:8443/x", + "output": "https://[2001:db8::1]:8443/x" }, { "name": "surrounding whitespace is stripped, not escaped", @@ -221,7 +237,7 @@ "error": "scheme_forbidden" }, { - "name": "mailto scheme is refused (harvesting vector, not a fetchable resource)", + "name": "mailto scheme is refused (allowlist: only http and https; also a harvesting vector)", "input": "mailto:josé@example.com", "error": "scheme_forbidden" }, @@ -230,10 +246,40 @@ "input": "mailto:someone@example.com", "error": "scheme_forbidden" }, + { + "name": "ftp scheme is refused (allowlist: only http and https)", + "input": "ftp://example.com/file.zip", + "error": "scheme_forbidden" + }, + { + "name": "blob scheme is refused", + "input": "blob:https://example.com/9d1b3b2a", + "error": "scheme_forbidden" + }, + { + "name": "intent scheme is refused (android app launcher)", + "input": "intent://scan/#Intent;scheme=zxing;end", + "error": "scheme_forbidden" + }, + { + "name": "gopher scheme is refused", + "input": "gopher://example.com/1/", + "error": "scheme_forbidden" + }, + { + "name": "ws scheme is refused (not a navigable web resource)", + "input": "ws://example.com/socket", + "error": "scheme_forbidden" + }, + { + "name": "uppercase forbidden scheme is still refused", + "input": "FTP://example.com/file.zip", + "error": "scheme_forbidden" + }, { "name": "scheme with no body names no resource", "input": "https:", - "error": "unnormalizable" + "error": "no_authority" }, { "name": "empty host label is not resolvable", diff --git a/internal/validation/uri.go b/internal/validation/uri.go index e373ee8..a4b1470 100644 --- a/internal/validation/uri.go +++ b/internal/validation/uri.go @@ -43,25 +43,22 @@ var ( // See testdata/uri_vectors.json, which pins that agreement. hostProfile = idna.New(idna.MapForLookup(), idna.BidiRule(), idna.VerifyDNSLength(true)) - // disallowedURIs are schemes refused outright rather than normalized. These - // values reach `embed.external.uri` and richtext `#link.uri`, both of which - // clients render as hrefs, and normalization would otherwise happily - // *repair* such a URI into a schema-valid one and sign it into a federated - // record that every downstream consumer inherits. + // allowedURIs are the only schemes NormalizeURI will emit. These values + // reach `embed.external.uri`, `embed.external.sources[].uri` and richtext + // `#link.uri`, all of which clients render as hrefs, and normalization + // would otherwise happily *repair* an unsafe URI into a schema-valid one + // and sign it into a federated record that every downstream consumer + // inherits. // - // Two categories, refused for different reasons: - // - javascript/data/vbscript execute or inline content in the renderer, - // so a stored one is stored XSS in any client that does not defend - // itself. - // - file/mailto do not name a fetchable remote resource. file: points at - // the viewer's own disk, and mailto: in a public federated feed is an - // address-harvesting and spam vector rather than a link to content. - disallowedURIs = map[string]struct{}{ - "javascript": {}, - "data": {}, - "vbscript": {}, - "file": {}, - "mailto": {}, + // This is an allowlist, not a blocklist, on purpose. A blocklist of + // javascript/data/vbscript/file/mailto still waved through ftp:, blob:, + // intent:, gopher: and every custom app scheme, none of which names a web + // resource a browser should navigate a user to from a feed. A link in a + // post is a web link; anything else is refused with + // ErrURISchemeNotAllowed rather than guessed at. + allowedURIs = map[string]struct{}{ + "http": {}, + "https": {}, } ) @@ -80,10 +77,14 @@ var ( // and "view-source:…" are both rejected). ErrURIBadScheme = errors.New("uri scheme is not valid for the atproto uri format") - // ErrURISchemeNotAllowed is returned for a scheme that names executable or - // inline content, or that does not name a fetchable remote resource at all. - // See disallowedURIs for the set and the reasoning. - ErrURISchemeNotAllowed = errors.New("uri scheme is not allowed in a rendered link") + // ErrURISchemeNotAllowed is returned for any scheme other than http or + // https. See allowedURIs for the reasoning. + ErrURISchemeNotAllowed = errors.New("uri scheme is not allowed in a rendered link (only http and https are accepted)") + + // ErrURINoAuthority is returned for an http(s) URI with no "//host" part + // ("https:foo", "https://", "https:///path"). Such a string satisfies the + // atproto format but no browser will parse it, so it cannot be a link. + ErrURINoAuthority = errors.New("uri has no host") // ErrURITooLong is returned for a URI beyond the atproto length cap, either // as supplied or after percent-encoding expanded it. @@ -103,7 +104,9 @@ func ValidURI(raw string) bool { } // NormalizeURI coerces raw into a string that satisfies the atproto `uri` -// format, returning an error only when no valid URI can be recovered. +// format, returning an error when no valid URI can be recovered or when the +// URI is not an http(s) web link (see allowedURIs): every field this feeds is +// rendered as an href, so only a URL a browser will navigate to is accepted. // // The transform is meaning-preserving. Bytes outside printable ASCII are // percent-encoded and a non-ASCII host is punycoded; both name the exact same @@ -122,8 +125,7 @@ func ValidURI(raw string) bool { // Encoding is done by splitting the URI into scheme / authority / remainder // with plain string operations rather than net/url. url.Parse rejects several // inputs that are trivially recoverable — a stray '%' that is not an escape, an -// interior tab, a non-ASCII userinfo, an `at://` URI whose DID reads as a port -// — and round-tripping through url.URL.String() decodes reserved characters in +// interior tab, a non-ASCII userinfo — and round-tripping through url.URL.String() decodes reserved characters in // the path. Both behaviours are the opposite of what this function promises. // // NormalizeURI is idempotent: input that already conforms is returned untouched, @@ -153,10 +155,22 @@ func NormalizeURI(raw string) (string, error) { if !atprotoScheme.MatchString(scheme) { return "", fmt.Errorf("%w: %q", ErrURIBadScheme, scheme) } - if _, blocked := disallowedURIs[scheme]; blocked { + if _, allowed := allowedURIs[scheme]; !allowed { return "", fmt.Errorf("%w: %q", ErrURISchemeNotAllowed, scheme) } + // An http(s) URI must be hierarchical with a host. "https:foo" and + // "https://" satisfy the atproto format and the scheme check, but the + // WHATWG parser every client renders through rejects them, so signing one + // would produce a record whose link silently vanishes in the UI. + rest := trimmed[len(match[0]):] + if !strings.HasPrefix(rest, "//") { + return "", fmt.Errorf("%w: %s URI has no authority (expected %s://host/…)", ErrURINoAuthority, scheme, scheme) + } + if authority, _ := splitAuthority(rest[2:]); hostOf(authority) == "" { + return "", fmt.Errorf("%w: %s URI has an empty host", ErrURINoAuthority, scheme) + } + if ValidURI(trimmed) { return trimmed, nil } @@ -166,21 +180,14 @@ func NormalizeURI(raw string) (string, error) { out.WriteString(scheme) out.WriteByte(':') - rest := trimmed[len(match[0]):] - if strings.HasPrefix(rest, "//") { - out.WriteString("//") - authority, remainder := splitAuthority(rest[2:]) - encoded, err := encodeAuthority(authority) - if err != nil { - return "", err - } - out.WriteString(encoded) - out.WriteString(escapeNonGraphBytes(remainder)) - } else { - // Opaque URI (urn:, magnet:, at: without an authority, …): everything after - // the scheme is encoded as-is. - out.WriteString(escapeNonGraphBytes(rest)) + out.WriteString("//") + authority, remainder := splitAuthority(rest[2:]) + encoded, err := encodeAuthority(authority) + if err != nil { + return "", err } + out.WriteString(encoded) + out.WriteString(escapeNonGraphBytes(remainder)) normalized := out.String() if len(normalized) > maxURILength { @@ -202,6 +209,26 @@ func splitAuthority(s string) (authority, remainder string) { return s, "" } +// hostOf returns the host portion of an authority: userinfo and port stripped. +// Only emptiness is decided on the result; encodeAuthority does the real work. +func hostOf(authority string) string { + host := authority + if at := strings.LastIndex(host, "@"); at >= 0 { + host = host[at+1:] + } + if strings.HasPrefix(host, "[") { + // IPv6 literal: the closing bracket ends the host. + if end := strings.Index(host, "]"); end >= 0 { + return host[:end+1] + } + return host + } + if colon := strings.LastIndex(host, ":"); colon >= 0 && isAllDigits(host[colon+1:]) { + host = host[:colon] + } + return host +} + // encodeAuthority punycodes a non-ASCII host and percent-encodes any userinfo, // leaving the port untouched. // diff --git a/internal/validation/uri_test.go b/internal/validation/uri_test.go index ef27547..6978a74 100644 --- a/internal/validation/uri_test.go +++ b/internal/validation/uri_test.go @@ -21,6 +21,7 @@ var errorClasses = map[string]error{ "no_scheme": ErrURINoScheme, "bad_scheme": ErrURIBadScheme, "scheme_forbidden": ErrURISchemeNotAllowed, + "no_authority": ErrURINoAuthority, "too_long": ErrURITooLong, "unnormalizable": ErrURIUnnormalizable, } @@ -154,6 +155,9 @@ func TestNormalizeURIErrorsAreDescriptive(t *testing.T) { {"missing scheme", "example.com/path", ErrURINoScheme, "scheme"}, {"scheme with digits", "s3://bucket/key", ErrURIBadScheme, "s3"}, {"forbidden scheme", "javascript:alert(1)", ErrURISchemeNotAllowed, "javascript"}, + {"non-web scheme", "ftp://example.com/x", ErrURISchemeNotAllowed, "ftp"}, + {"no authority", "https:isbn:123", ErrURINoAuthority, "authority"}, + {"empty host", "https:///path", ErrURINoAuthority, "host"}, {"too long", "https://example.com/" + strings.Repeat("a", 9000), ErrURITooLong, "max"}, {"unresolvable host", "https://ä..com/x", ErrURIUnnormalizable, "punycode"}, }