mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
fix(storage): encode non-ASCII filenames in Content-Disposition
Preserve non-ASCII attachment filenames with RFC 5987 while retaining a safe ASCII fallback.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user