diff --git a/pkg/helpers/testdata/fuzz/FuzzDecodeURIIfNeeded/b1dd9e28e8156f0d b/pkg/helpers/testdata/fuzz/FuzzDecodeURIIfNeeded/b1dd9e28e8156f0d new file mode 100644 index 00000000..12aa8213 --- /dev/null +++ b/pkg/helpers/testdata/fuzz/FuzzDecodeURIIfNeeded/b1dd9e28e8156f0d @@ -0,0 +1,2 @@ +go test fuzz v1 +string("steAm://%2F%3F") diff --git a/pkg/helpers/testdata/fuzz/FuzzDecodeURIIfNeeded/ecaa49a609591b96 b/pkg/helpers/testdata/fuzz/FuzzDecodeURIIfNeeded/ecaa49a609591b96 new file mode 100644 index 00000000..7626dc9b --- /dev/null +++ b/pkg/helpers/testdata/fuzz/FuzzDecodeURIIfNeeded/ecaa49a609591b96 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("http://?#?#%") diff --git a/pkg/helpers/uris.go b/pkg/helpers/uris.go index 94b7714d..f95033ab 100644 --- a/pkg/helpers/uris.go +++ b/pkg/helpers/uris.go @@ -78,12 +78,13 @@ func DecodeURIIfNeeded(uri string) string { // slash structure while decoding percent-encoded characters. rest := strings.TrimRight(parsed.Rest, "/") segments := strings.Split(rest, "/") + // Re-encoder for gen-delims that would change URI structure on a + // second parse pass: / (path sep), ? (query), # (fragment). + reenc := strings.NewReplacer("/", "%2F", "?", "%3F", "#", "%23") for i, seg := range segments { decoded, err := url.PathUnescape(seg) if err == nil && utf8.ValidString(decoded) { - // Re-encode any slashes from %2F so they don't become - // structural separators on subsequent passes - segments[i] = strings.ReplaceAll(decoded, "/", "%2F") + segments[i] = reenc.Replace(decoded) } } reconstructed := parsed.Scheme + "://" + strings.Join(segments, "/") @@ -95,18 +96,23 @@ func DecodeURIIfNeeded(uri string) string { // Handle standard web schemes (http/https) if shared.IsStandardSchemeForDecoding(schemeLower) { - // Extract fragment from query if present (only for http/https) + // Per RFC 3986, '#' introduces the fragment and takes precedence over + // '?', which ParseURIComponents picks up as the query separator. + // Extract fragment from the raw URI first so that a fragment containing + // '?' doesn't shift on a second parse pass (idempotence). var fragment string - query := parsed.Query - if idx := strings.Index(query, "#"); idx >= 0 { - fragment = query[idx+1:] - query = query[:idx] + fragURI := uri + if idx := strings.Index(uri, "#"); idx >= 0 { + fragment = uri[idx+1:] + fragURI = uri[:idx] } + hParsed := virtualpath.ParseURIComponents(fragURI) + query := hParsed.Query // Split rest into userinfo@host and path // Format: [userinfo@]host/path var userinfo, host, pathPart string - rest := parsed.Rest + rest := hParsed.Rest // Check for userinfo (use LastIndex to handle @ in passwords) if idx := strings.LastIndex(rest, "@"); idx >= 0 { diff --git a/pkg/helpers/uris_test.go b/pkg/helpers/uris_test.go index 0032970b..27856516 100644 --- a/pkg/helpers/uris_test.go +++ b/pkg/helpers/uris_test.go @@ -353,7 +353,7 @@ func TestDecodeURIIfNeeded_EdgeCases(t *testing.T) { { name: "kodi_with_fragment", input: "kodi-movie://456/The%20Matrix#play", - expected: "kodi-movie://456/The Matrix#play", // Fragment kept as part of name + expected: "kodi-movie://456/The Matrix%23play", // '#' re-encoded to keep it in path, not fragment }, { name: "http_with_query_and_fragment",