package http import ( "fmt" "strings" ) // fallbackDownloadName is used when nothing printable survives sanitisation. const fallbackDownloadName = "download" // contentDisposition builds a Content-Disposition header value for a stored, // user-supplied filename following RFC 6266: an ASCII-only quoted fallback in // filename= and the exact UTF-8 name, percent-encoded per RFC 8187, in // filename*=. Neither parameter can carry quotes, backslashes, control // characters (CR/LF) or parameter separators taken from the filename, so a // crafted upload name cannot inject extra header parameters or headers. func contentDisposition(disposition, filename string) string { name := strings.Map(func(r rune) rune { if r < 0x20 || r == 0x7f { return -1 // drop control characters, including CR and LF } return r }, filename) name = strings.TrimSpace(name) if name == "" { name = fallbackDownloadName } ascii := strings.Map(func(r rune) rune { if r > 0x7e || r == '"' || r == '\\' { return '_' } return r }, name) return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, ascii, encodeRFC8187(name)) } // encodeRFC8187 percent-encodes every byte of s that is not an attr-char // (RFC 8187 ยง3.2.1). func encodeRFC8187(s string) string { const hex = "0123456789ABCDEF" var b strings.Builder for i := 0; i < len(s); i++ { c := s[i] if isAttrChar(c) { b.WriteByte(c) continue } b.WriteByte('%') b.WriteByte(hex[c>>4]) b.WriteByte(hex[c&0x0f]) } return b.String() } func isAttrChar(c byte) bool { switch { case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9': return true } return strings.IndexByte("!#$&+-.^_`|~", c) >= 0 }