harden lastfm playlist parsing and url validation

This commit is contained in:
2026-04-21 19:10:50 +02:00
parent c67be72869
commit ba97fe85fe
2 changed files with 54 additions and 2 deletions

View File

@@ -1640,6 +1640,9 @@ func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (last
if opts.PlaylistURL == "" { if opts.PlaylistURL == "" {
return lastFMOptions{}, fmt.Errorf("missing playlist url") return lastFMOptions{}, fmt.Errorf("missing playlist url")
} }
if !isValidLastFMPlaylistURL(opts.PlaylistURL) {
return lastFMOptions{}, fmt.Errorf("playlist url must be a last.fm url")
}
if !isAllowedSearchSource(opts.Source) { if !isAllowedSearchSource(opts.Source) {
return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.Source) return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.Source)
} }
@@ -1649,11 +1652,27 @@ func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (last
return opts, nil return opts, nil
} }
func isValidLastFMPlaylistURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil || u.Host == "" {
return false
}
s := strings.ToLower(strings.TrimSpace(u.Scheme))
if s != "http" && s != "https" {
return false
}
h := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(u.Host), "www."))
return h == "last.fm" || strings.HasSuffix(h, ".last.fm")
}
func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) { func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) {
parsed, err := url.Parse(playlistURL) parsed, err := url.Parse(playlistURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", nil, fmt.Errorf("invalid playlist url") return "", nil, fmt.Errorf("invalid playlist url")
} }
if !isValidLastFMPlaylistURL(playlistURL) {
return "", nil, fmt.Errorf("invalid playlist url")
}
client := netutil.NewHTTPClient(30*time.Second, verifySSL) client := netutil.NewHTTPClient(30*time.Second, verifySSL)
page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1) page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1)
@@ -1711,7 +1730,7 @@ func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistU
break break
} }
all = append(all, tracks...) all = append(all, tracks...)
if !strings.Contains(body, "Show more") { if !strings.Contains(strings.ToLower(body), "show more") {
break break
} }
} }
@@ -1829,7 +1848,7 @@ func extractLastFMTracksFromMirrorMarkdown(md string) (string, []lastFMTrack) {
title = strings.TrimSpace(html.UnescapeString(m[1])) title = strings.TrimSpace(html.UnescapeString(m[1]))
} }
} }
if !strings.HasPrefix(line, "|") || !strings.Contains(line, "Play track") { if !strings.HasPrefix(line, "|") || !strings.Contains(strings.ToLower(line), "play track") {
continue continue
} }
cols := splitMarkdownTableRow(line) cols := splitMarkdownTableRow(line)

View File

@@ -94,6 +94,28 @@ func TestParseLastFMArgsOptions(t *testing.T) {
} }
} }
func TestParseLastFMArgsRejectsNonLastFMURL(t *testing.T) {
_, err := parseLastFMArgs([]string{"https://example.com/user/x/playlists/123"}, "qobuz", "")
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "last.fm") {
t.Fatalf("expected last.fm url validation error, got %v", err)
}
}
func TestIsValidLastFMPlaylistURL(t *testing.T) {
if !isValidLastFMPlaylistURL("https://www.last.fm/user/x/playlists/123") {
t.Fatalf("expected canonical last.fm playlist url to be valid")
}
if !isValidLastFMPlaylistURL("http://last.fm/user/x/playlists/123") {
t.Fatalf("expected http last.fm playlist url to be valid")
}
if isValidLastFMPlaylistURL("ftp://last.fm/user/x/playlists/123") {
t.Fatalf("expected non-http scheme to be invalid")
}
if isValidLastFMPlaylistURL("https://example.com/user/x/playlists/123") {
t.Fatalf("expected non-last.fm host to be invalid")
}
}
func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) { func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) {
html := `<h1 class="playlisting-playlist-header-title">Road &amp; Rain</h1> html := `<h1 class="playlisting-playlist-header-title">Road &amp; Rain</h1>
<div data-playlisting-entry-count="2"></div> <div data-playlisting-entry-count="2"></div>
@@ -191,6 +213,17 @@ func TestExtractLastFMTracksFromMirrorMarkdown(t *testing.T) {
} }
} }
func TestExtractLastFMTracksFromMirrorMarkdownLowercasePlayTrack(t *testing.T) {
md := `Title: My Playlist | user playlists | Last.fm
| Play | Image | Loved | Name | Artist name | Buy | Options | Duration |
| --- | --- | --- | --- | --- | --- | --- | --- |
| [play track](https://x) | [img](https://i) | x | [Song A](https://a) | [Artist A](https://aa) | | | 3:00 |`
_, tracks := extractLastFMTracksFromMirrorMarkdown(md)
if len(tracks) != 1 {
t.Fatalf("tracks len = %d, want 1", len(tracks))
}
}
func TestParseSearchArgsAllowsFirstAndOutputFileButCallerCanReject(t *testing.T) { func TestParseSearchArgsAllowsFirstAndOutputFileButCallerCanReject(t *testing.T) {
opts, err := parseSearchArgs([]string{"q", "--first", "--output-file", "/tmp/out.json"}, 20) opts, err := parseSearchArgs([]string{"q", "--first", "--output-file", "/tmp/out.json"}, 20)
if err != nil { if err != nil {