Files
pad/internal/server/content_disposition.go
T
xarmian 547527d7dc fix(server): Content-Disposition carries a non-ASCII or unquotable filename in filename* with an ASCII fallback, so NBSP and zero-width names no longer reach clients as Go escape text (BUG-3190) (#1569)
* fix(server): Content-Disposition carries a non-ASCII or unquotable filename in filename* with an ASCII fallback, so NBSP and zero-width names no longer reach clients as Go escape text (BUG-3190)

* test(server): the Content-Disposition population carries characters the extended form must encode, and the doc comment survives gofmt (BUG-3190)
2026-09-26 09:47:51 -04:00

81 lines
2.7 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"strings"
)
// contentDisposition builds a Content-Disposition header value that carries
// name faithfully (BUG-3190).
//
// A name of printable ASCII with no quote or backslash is written as
// `filename="name"` alone: byte-identical to the `%q` form every site used,
// which is the same string for such a name. Any other name gets RFC 6266's
// two parameters: an ASCII fallback in `filename`, for clients that do not
// read the extended form, and the exact name in `filename*` (RFC 8187: the
// UTF-8 charset, an empty language tag, the percent-encoded bytes). A client
// that reads both
// prefers `filename*`; Go's mime.ParseMediaType, which the CLI and both MCP
// transports use, does exactly that.
//
// What this replaces was `filename=%q`, which put raw UTF-8 in a parameter
// defined as ISO-8859-1 and, worse, wrote Go escape TEXT for any rune
// strconv.IsPrint rejects: a stored "a<NBSP>b.txt" reached every Go client
// as the characters `a b.txt`, a different name carrying a backslash,
// which a Windows client reads as a path separator.
//
// The caller passes the name it means to serve; sanitising it (the attachment
// download drops control, quote and backslash runes) stays the caller's job.
func contentDisposition(disposition, name string) string {
if plainHeaderFilename(name) {
return disposition + `; filename="` + name + `"`
}
return disposition + `; filename="` + asciiFilenameFallback(name) + `"; filename*=UTF-8''` + encodeRFC8187(name)
}
// plainHeaderFilename reports whether name can travel as a quoted-string
// with no escaping and no loss: printable ASCII, no quote, no backslash.
func plainHeaderFilename(name string) bool {
for i := 0; i < len(name); i++ {
c := name[i]
if c < 0x20 || c > 0x7e || c == '"' || c == '\\' {
return false
}
}
return true
}
// asciiFilenameFallback replaces every rune plainHeaderFilename would reject
// with '_', so an ASCII extension survives ("会議.pdf" → "__.pdf").
func asciiFilenameFallback(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
if r >= 0x20 && r <= 0x7e && r != '"' && r != '\\' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}
// encodeRFC8187 percent-encodes every byte of s that is not an RFC 8187
// attr-char (ALPHA / DIGIT / "!#$&+-.^_`|~").
func encodeRFC8187(s string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
b.Grow(len(s) * 3)
for i := 0; i < len(s); i++ {
c := s[i]
if ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') ||
strings.IndexByte("!#$&+-.^_`|~", c) >= 0 {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0x0f])
}
return b.String()
}