From e4813fe053405bc2bffafc7923140e43a5d09f88 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 23 Sep 2026 09:34:38 -0400 Subject: [PATCH] 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. --- utils/str/content_disposition.go | 15 +++++++++++---- utils/str/content_disposition_test.go | 10 ++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/utils/str/content_disposition.go b/utils/str/content_disposition.go index 22a28fdbd..34ae8a5c6 100644 --- a/utils/str/content_disposition.go +++ b/utils/str/content_disposition.go @@ -12,7 +12,11 @@ import ( "github.com/deluan/sanitize" ) -const maxFilenameBytes = 255 +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. @@ -36,8 +40,11 @@ func splitFilename(filename string) (stem, ext string) { return r }, SanitizeFilename(strings.ToValidUTF8(filename, "_"))) ext = path.Ext(name) + if len(ext) > maxExtensionBytes { + ext = "" + } stem = strings.TrimSuffix(name, ext) - if limit := max(maxFilenameBytes-len(ext), 0); len(stem) > limit { + if limit := maxFilenameBytes - len(ext); len(stem) > limit { for limit > 0 && !utf8.RuneStart(stem[limit]) { limit-- } @@ -47,8 +54,8 @@ func splitFilename(filename string) (stem, ext string) { } func joinFilename(stem, ext string) string { - stem = cmp.Or(strings.TrimSpace(stem), "download") - return strings.TrimRight(stem+ext, " .") + stem = cmp.Or(strings.TrimSpace(stem), fallbackFilename) + return cmp.Or(strings.TrimRight(stem+ext, " ."), fallbackFilename) } func toASCII(s string) string { diff --git a/utils/str/content_disposition_test.go b/utils/str/content_disposition_test.go index 2a768a1ca..944392a4e 100644 --- a/utils/str/content_disposition_test.go +++ b/utils/str/content_disposition_test.go @@ -61,6 +61,16 @@ var _ = Describe("ContentDispositionAttachment", func() { Expect(asciiName("Loose End. ")).To(Equal("Loose End")) }) + It("falls back to a placeholder when only dots are left", func() { + Expect(str.ContentDispositionAttachment("...")).To(Equal(`attachment; filename="download"`)) + }) + + It("caps a long name whose last dot is not an extension", func() { + name := asciiName("a." + strings.Repeat("x", 300)) + Expect(len(name)).To(BeNumerically("<=", 255)) + Expect(name).To(HavePrefix("a.xxx")) + }) + It("replaces path separators and reserved characters", func() { Expect(asciiName("AC/DC: Live, 1979?.zip")).To(Equal("AC_DC_ Live_ 1979_.zip")) })