fix(extensions): anchor URL handler patterns to the host

MatchesURL treated patterns as substrings of the whole URL, so
"spotify.com" matched any URL embedding it in a query parameter and a
hostile query string could route a link to the wrong handler. Patterns
now anchor to the URL host (exact domain or subdomain, optional path
prefix); "scheme:" patterns anchor to the URI front. With several
matching handlers, FindURLHandler now breaks the tie via the user's
metadata provider priority instead of Go's random map order.
This commit is contained in:
zarzet
2026-07-26 19:14:31 +07:00
parent 42c266eafc
commit fa7ef84b39
3 changed files with 121 additions and 6 deletions
+42 -2
View File
@@ -371,17 +371,57 @@ func (m *ExtensionManifest) HasURLHandler() bool {
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
}
// MatchesURL reports whether one of the handler's patterns matches the URL.
// Web patterns are anchored to the URL's host (exact domain or subdomain,
// optional path prefix) — never matched as a raw substring, so "spotify.com"
// cannot match a URL that merely embeds it in a query parameter. Patterns
// ending in ":" (e.g. "spotify:") match custom URI schemes as prefixes.
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
if !m.HasURLHandler() {
return false
}
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
parsed, parseErr := url.Parse(urlStr)
for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern))
if strings.Contains(urlStr, pattern) {
return true
if pattern == "" {
continue
}
// Scheme patterns anchor to the front of the URI.
if !strings.Contains(pattern, "/") && strings.HasSuffix(pattern, ":") {
if strings.HasPrefix(urlStr, pattern) {
return true
}
continue
}
if parseErr != nil || parsed.Host == "" {
continue
}
host := parsed.Hostname()
urlPath := parsed.Path
if urlPath == "" {
urlPath = "/"
}
if idx := strings.Index(pattern, "://"); idx >= 0 {
pattern = pattern[idx+3:]
}
patternHost, patternPath, hasPath := strings.Cut(pattern, "/")
if patternHost == "" {
continue
}
if host != patternHost && !strings.HasSuffix(host, "."+patternHost) {
continue
}
if hasPath && patternPath != "" &&
!strings.HasPrefix(urlPath, "/"+patternPath) {
continue
}
return true
}
return false
}
+32 -4
View File
@@ -133,16 +133,44 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri
return tracks, nil
}
// FindURLHandler returns the enabled handler matching the URL. When several
// extensions match (e.g. two Spotify handlers), the user's metadata provider
// priority breaks the tie deterministically instead of Go's random map
// iteration order.
func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
matches := make([]*loadedExtension, 0, 2)
for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
return newExtensionProviderWrapper(ext)
matches = append(matches, ext)
}
}
return nil
m.mu.RUnlock()
if len(matches) == 0 {
return nil
}
if len(matches) > 1 {
rank := map[string]int{}
for i, id := range GetMetadataProviderPriority() {
rank[strings.ToLower(strings.TrimSpace(id))] = i
}
sort.SliceStable(matches, func(i, j int) bool {
ri, oki := rank[strings.ToLower(matches[i].ID)]
rj, okj := rank[strings.ToLower(matches[j].ID)]
switch {
case oki && okj:
return ri < rj
case oki:
return true
case okj:
return false
default:
return matches[i].ID < matches[j].ID
}
})
}
return newExtensionProviderWrapper(matches[0])
}
type ExtURLHandleResultWithExtID struct {
+47
View File
@@ -777,3 +777,50 @@ func TestParseExtensionAuxiliaryResults(t *testing.T) {
t.Fatalf("unexpected lyrics result: %+v", lyrics)
}
}
func TestMatchesURLHostAnchored(t *testing.T) {
manifest := &ExtensionManifest{
URLHandler: &URLHandlerConfig{
Enabled: true,
Patterns: []string{"spotify.com", "deezer.page.link", "spotify:"},
},
}
for _, urlStr := range []string{
"https://open.spotify.com/track/abc",
"https://spotify.com/track/abc",
"HTTPS://OPEN.SPOTIFY.COM/track/ABC",
"https://deezer.page.link/xyz",
"spotify:track:abc123",
} {
if !manifest.MatchesURL(urlStr) {
t.Fatalf("expected match for %q", urlStr)
}
}
for _, urlStr := range []string{
// The old substring matching accepted all of these.
"https://evil.example/?next=https://spotify.com/track/abc",
"https://notspotify.com/track/abc",
"https://spotify.com.evil.example/track/abc",
"https://example.com/spotify.com",
"not a url at all",
} {
if manifest.MatchesURL(urlStr) {
t.Fatalf("expected no match for %q", urlStr)
}
}
withPath := &ExtensionManifest{
URLHandler: &URLHandlerConfig{
Enabled: true,
Patterns: []string{"youtube.com/watch"},
},
}
if !withPath.MatchesURL("https://www.youtube.com/watch?v=abc") {
t.Fatal("expected host+path prefix to match")
}
if withPath.MatchesURL("https://www.youtube.com/playlist?list=abc") {
t.Fatal("expected different path to not match")
}
}