navidrome/server/nativeapi/playlists.go
Adrián Sánchez Zapico a3f41fb422
sec(server): sanitize user-controlled filenames in Content-Disposition (#5895)
* sec(server): sanitize user-controlled filenames in Content-Disposition

Playlist export, Subsonic download and public share download built the
Content-Disposition header by interpolating a user-controlled name into a
quoted-string with fmt.Sprintf. A name containing a double quote closes the
string early and the rest is parsed as additional parameters, so a playlist
named `party"; filename="evil.html` yielded

    attachment; filename="party"; filename="evil.html.m3u"

letting whoever chose the name decide what the browser saves the download as.
The names come from playlists, album/artist names and media file tags.

Go's net/http already rewrites CR and LF in header values to spaces, so
response splitting was not reachable; parameter injection was.

Add str.ContentDispositionAttachment, which emits a sanitized ASCII-only
quoted `filename` plus an RFC 5987 `filename*` carrying the original UTF-8
name, and use it at all four call sites. The `filename*` parameter also fixes
non-ASCII names, which previously went out raw or were mangled by sanitizing.

Signed-off-by: zapisanchez <zapisanchez@gmail.com>

* fix(server): keep download names intact and sanitize filename*

Rework ContentDispositionAttachment after review. Names with no ASCII
letters now fall back to download.<ext> instead of a bare extension
(東京.mp3 gave filename="mp3"). filename* is built from the same
sanitized name as the ASCII fallback, so path separators, reserved
characters, control and bidi characters, and invalid UTF-8 no longer
reach it. The ASCII fallback transliterates accents and typographic
punctuation (Legião -> Legiao, She’s -> She's) through the existing
sanitize.Accents and str.Clear helpers, keeps leading dots, and only
trims trailing ones. Names are capped at 255 bytes, keeping the
extension.

Pure ASCII names now get only the quoted filename parameter, so the
header for them matches the previous output byte for byte. filename*
is encoded with mime.FormatMediaType instead of a hand-written RFC 5987
encoder. Adds tests for the M3U export and Subsonic download headers.

* fix(server): handle dot-only names and long fake extensions

A name made only of dots trimmed down to an empty filename. It now
falls back to download, like an empty stem does.

path.Ext treats anything after the last dot as the extension, so a long
suffix with no real extension was kept whole and replaced the stem with
download, going past the 255-byte cap. Suffixes longer than 16 bytes
are now treated as part of the stem and truncated with it.

Neither case is reachable from the current call sites, which always
append a short extension.

---------

Signed-off-by: zapisanchez <zapisanchez@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-23 09:47:16 -04:00

251 lines
7.7 KiB
Go

package nativeapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/str"
)
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
// writePlaylistError maps a playlist service error to an HTTP status, or defaultStatus if unknown.
func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) {
switch {
case errors.Is(err, model.ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
case errors.Is(err, model.ErrNotAuthorized):
http.Error(w, err.Error(), http.StatusForbidden)
case errors.Is(err, model.ErrPlaylistNotEditable):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, err.Error(), defaultStatus)
}
}
func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
plsId := chi.URLParam(r, "playlistId")
tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r))
if tracks == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r)
}
}
func getPlaylist(pls playlists.Playlists) http.HandlerFunc {
handler := playlistTracksHandler(pls, rest.GetAll, func(r *http.Request) bool {
return req.Params(r).Int64Or("_start", 0) == 0
})
return func(w http.ResponseWriter, r *http.Request) {
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
handleExportPlaylist(pls)(w, r)
return
}
handler(w, r)
}
}
func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc {
return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true })
}
func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pl, err := pls.ImportM3U(ctx, r.Body)
if err != nil {
log.Error(r.Context(), "Error parsing playlist", err)
// TODO: consider returning StatusBadRequest for playlists that are malformed
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
_, err = w.Write([]byte(pl.ToM3U8())) //nolint:gosec
if err != nil {
log.Error(ctx, "Error sending m3u contents", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
plsId := chi.URLParam(r, "playlistId")
playlist, err := pls.GetWithTracks(ctx, plsId)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Playlist not found", "playlistId", plsId)
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error(ctx, "Error retrieving the playlist", "playlistId", plsId, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name)
w.Header().Set("Content-Type", "audio/x-mpegurl")
w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(playlist.Name+".m3u"))
_, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec
if err != nil {
log.Error(ctx, "Error sending playlist", "name", playlist.Name)
return
}
}
}
func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
ids := p.Strings("id")
err := pls.RemoveTracks(r.Context(), playlistId, ids)
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err)
writePlaylistError(w, err, http.StatusInternalServerError)
return
}
writeDeleteManyResponse(w, r, ids)
}
}
func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
type addTracksPayload struct {
Ids []string `json:"ids"`
AlbumIds []string `json:"albumIds"`
ArtistIds []string `json:"artistIds"`
Discs []model.DiscID `json:"discs"`
}
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
var payload addTracksPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count, c := 0, 0
if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil {
writePlaylistError(w, err, http.StatusBadRequest)
return
}
count += c
if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil {
writePlaylistError(w, err, http.StatusBadRequest)
return
}
count += c
if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil {
writePlaylistError(w, err, http.StatusBadRequest)
return
}
count += c
if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil {
writePlaylistError(w, err, http.StatusBadRequest)
return
}
count += c
// Must return an object with an ID, to satisfy ReactAdmin `create` call
_, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func reorderItem(pls playlists.Playlists) http.HandlerFunc {
type reorderPayload struct {
InsertBefore string `json:"insert_before"`
}
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
id := p.IntOr(":id", 0)
if id == 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var payload reorderPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newPos, err := strconv.Atoi(payload.InsertBefore)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = pls.ReorderTrack(ctx, playlistId, id, newPos)
if err != nil {
writePlaylistError(w, err, http.StatusBadRequest)
return
}
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
trackId, _ := p.String(":id")
playlists, err := svc.GetPlaylists(r.Context(), trackId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data, err := json.Marshal(playlists)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data) //nolint:gosec
}
}
func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
playlistId := chi.URLParamFromCtx(ctx, "id")
return pls.SetImage(ctx, playlistId, reader, ext)
})
}
func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc {
return handleImageDelete(func(ctx context.Context) error {
playlistId := chi.URLParamFromCtx(ctx, "id")
return pls.RemoveImage(ctx, playlistId)
})
}