navidrome/utils/str/content_disposition.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

80 lines
1.9 KiB
Go
Raw Permalink Normal View History

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 15:47:16 +02:00
package str
import (
"cmp"
"fmt"
"mime"
"path"
"strings"
"unicode"
"unicode/utf8"
"github.com/deluan/sanitize"
)
const (
maxFilenameBytes = 255
maxExtensionBytes = 16
fallbackFilename = "download"
)
// ContentDispositionAttachment builds an RFC 6266 attachment header value for a user-controlled filename.
// Non-ASCII names also get a filename* parameter, which clients prefer over the ASCII fallback.
func ContentDispositionAttachment(filename string) string {
stem, ext := splitFilename(filename)
header := fmt.Sprintf("attachment; filename=%q", joinFilename(toASCII(stem), toASCII(ext)))
name := joinFilename(stem, ext)
if isASCII(name) {
return header
}
// FormatMediaType emits a percent-encoded filename* for non-ASCII values
extended := mime.FormatMediaType("attachment", map[string]string{"filename": name})
return header + strings.TrimPrefix(extended, "attachment")
}
func splitFilename(filename string) (stem, ext string) {
name := strings.Map(func(r rune) rune {
if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) {
return -1
}
return r
}, SanitizeFilename(strings.ToValidUTF8(filename, "_")))
ext = path.Ext(name)
if len(ext) > maxExtensionBytes {
ext = ""
}
stem = strings.TrimSuffix(name, ext)
if limit := maxFilenameBytes - len(ext); len(stem) > limit {
for limit > 0 && !utf8.RuneStart(stem[limit]) {
limit--
}
stem = stem[:limit]
}
return stem, ext
}
func joinFilename(stem, ext string) string {
stem = cmp.Or(strings.TrimSpace(stem), fallbackFilename)
return cmp.Or(strings.TrimRight(stem+ext, " ."), fallbackFilename)
}
func toASCII(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r == ',':
return '_'
case r >= ' ' && r <= '~':
return r
}
return -1
}, sanitize.Accents(SanitizeFilename(Clear(s))))
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}