From a40481805a8ed0bfa322deef6b56e01043270bdb Mon Sep 17 00:00:00 2001 From: BrianBoyCN Date: Thu, 16 Jul 2026 12:47:14 +0800 Subject: [PATCH] fix(storage): encode non-ASCII filenames in Content-Disposition Preserve non-ASCII attachment filenames with RFC 5987 while retaining a safe ASCII fallback. --- server/internal/storage/util.go | 71 +++++++++++++++++++++++- server/internal/storage/util_test.go | 82 +++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/server/internal/storage/util.go b/server/internal/storage/util.go index dab56fc3b..1b2aceafb 100644 --- a/server/internal/storage/util.go +++ b/server/internal/storage/util.go @@ -1,7 +1,9 @@ package storage import ( + "encoding/hex" "strings" + "unicode/utf8" ) // sanitizeFilename removes characters that could cause header injection in Content-Disposition. @@ -19,16 +21,81 @@ func sanitizeFilename(name string) string { return b.String() } +// asciiOnlyFilename returns an ASCII-only variant of name by replacing every +// non-ASCII rune with an underscore. It is used for the legacy filename="..." +// parameter when the original name requires RFC 5987 encoding. +func asciiOnlyFilename(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + if r > 0x7f { + b.WriteRune('_') + } else { + b.WriteRune(r) + } + } + return b.String() +} + +// needsRFC5987Encoding returns true when filename contains non-ASCII characters. +func needsRFC5987Encoding(name string) bool { + for _, r := range name { + if r > 0x7f { + return true + } + } + return false +} + +// rfc5987Encode percent-encodes name per RFC 5987 §3.2.1 for UTF-8 encoding. +// Only encodes bytes that are not "attr-char" (alphanumeric plus !#$&+-.^_`|~). +func rfc5987Encode(name string) string { + var b strings.Builder + b.Grow(len(name) * 3) // worst case: every byte encoded as %XX + for _, r := range name { + if r <= 0x7f && isAttrChar(byte(r)) { + b.WriteByte(byte(r)) + } else { + // Encode each byte of the UTF-8 representation + buf := make([]byte, 4) + n := utf8.EncodeRune(buf, r) + for i := 0; i < n; i++ { + b.WriteByte('%') + b.WriteString(strings.ToUpper(hex.EncodeToString(buf[i : i+1]))) + } + } + } + return b.String() +} + +// isAttrChar returns true for chars allowed in RFC 5987 attr-char. +func isAttrChar(c byte) bool { + return (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + strings.ContainsRune("!#$&+-.^_`|~", rune(c)) +} + func ContentDisposition(contentType, filename string) string { disposition := "attachment" if isInlineContentType(contentType) { disposition = "inline" } - return disposition + `; filename="` + sanitizeFilename(filename) + `"` + if !needsRFC5987Encoding(filename) { + return disposition + `; filename="` + sanitizeFilename(filename) + `"` + } + // Provide an ASCII-only fallback filename for legacy clients (RFC 6266) + // and the RFC 5987 filename* for modern clients. + asciiFallback := sanitizeFilename(asciiOnlyFilename(filename)) + return disposition + `; filename="` + asciiFallback + `"; filename*=UTF-8''` + rfc5987Encode(filename) } func AttachmentContentDisposition(filename string) string { - return `attachment; filename="` + sanitizeFilename(filename) + `"` + if !needsRFC5987Encoding(filename) { + return `attachment; filename="` + sanitizeFilename(filename) + `"` + } + asciiFallback := sanitizeFilename(asciiOnlyFilename(filename)) + return `attachment; filename="` + asciiFallback + `"; filename*=UTF-8''` + rfc5987Encode(filename) } // isInlineContentType returns true for media types that browsers should diff --git a/server/internal/storage/util_test.go b/server/internal/storage/util_test.go index c4c16f1ce..63396dddb 100644 --- a/server/internal/storage/util_test.go +++ b/server/internal/storage/util_test.go @@ -1,6 +1,9 @@ package storage -import "testing" +import ( + "strings" + "testing" +) func TestIsInlineContentType(t *testing.T) { cases := []struct { @@ -53,3 +56,80 @@ func TestContentDisposition(t *testing.T) { t.Fatalf("ContentDisposition svg = %q", got) } } + +func TestContentDispositionNonASCII(t *testing.T) { + // Chinese filename — should include filename* and an ASCII-only fallback. + got := ContentDisposition("image/webp", "微信图片_2026-04-09_162004_785.webp") + if !strings.Contains(got, "filename*=UTF-8''") { + t.Fatalf("ContentDisposition should include filename* for non-ASCII: %q", got) + } + const legacyPrefix = `filename="` + start := strings.Index(got, legacyPrefix) + if start == -1 { + t.Fatalf("ContentDisposition should keep ASCII fallback: %q", got) + } + // Legacy filename parameter must be ASCII only. + start += len(legacyPrefix) + end := strings.IndexByte(got[start:], '"') + if end == -1 { + t.Fatalf("ContentDisposition fallback is not terminated: %q", got) + } + fallback := got[start : start+end] + for _, r := range fallback { + if r > 0x7f { + t.Fatalf("legacy filename parameter must be ASCII only, got: %q", fallback) + } + } + // Modern filename* must contain the percent-encoded original name. + if !strings.Contains(got, "%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87") { + t.Fatalf("filename* should encode the original Chinese name: %q", got) + } + + // ASCII-only filename — no filename* + got2 := ContentDisposition("text/plain", "notes.txt") + if strings.Contains(got2, "filename*=") { + t.Fatalf("ContentDisposition should NOT include filename* for ASCII: %q", got2) + } + + // Mixed ASCII + special chars (but no non-ASCII) — no filename* + got3 := ContentDisposition("image/png", `nice"file;.png`) + if strings.Contains(got3, "filename*=") { + t.Fatalf("ContentDisposition should NOT include filename* for pure ASCII with special chars: %q", got3) + } +} + +func TestAttachmentContentDispositionNonASCII(t *testing.T) { + got := AttachmentContentDisposition("微信图片_2026-04-09_162004_785.webp") + wantPrefix := `attachment; filename="_____2026-04-09_162004_785.webp"; filename*=UTF-8''` + if !strings.HasPrefix(got, wantPrefix) { + t.Fatalf("AttachmentContentDisposition = %q, want prefix %q", got, wantPrefix) + } + if !strings.Contains(got, "%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87") { + t.Fatalf("AttachmentContentDisposition should encode the original Chinese name: %q", got) + } +} + +func TestRFC5987Encode(t *testing.T) { + got := rfc5987Encode("微信图片") + want := "%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87" + if got != want { + t.Fatalf("rfc5987Encode(%q) = %q, want %q", "微信图片", got, want) + } + // ASCII pass-through + if got2 := rfc5987Encode("hello.txt"); got2 != "hello.txt" { + t.Fatalf("rfc5987Encode(ASCII) = %q, want %q", got2, "hello.txt") + } + // Space and special chars are encoded + if got3 := rfc5987Encode("a b"); got3 != "a%20b" { + t.Fatalf("rfc5987Encode(space) = %q, want %q", got3, "a%20b") + } +} + +func TestNeedsRFC5987Encoding(t *testing.T) { + if needsRFC5987Encoding("hello.txt") { + t.Fatal("ASCII filename should not need RFC 5987 encoding") + } + if !needsRFC5987Encoding("微信图片.webp") { + t.Fatal("Chinese filename should need RFC 5987 encoding") + } +}