Files
multica/server/internal/storage/util.go
Bohan Jiang 424f67f7cb fix(security): normalize MIME type in isInlineContentType (#3050)
isInlineContentType is the security boundary that decides whether an
uploaded file is served with Content-Disposition: inline (renderable
in the document origin) or attachment. The SVG carve-out added in
#3023 to block stored-XSS via uploaded .svg only matched the exact
literal "image/svg+xml", so callers that supply "IMAGE/SVG+XML",
"image/svg+xml; charset=utf-8", or whitespace-padded variants would
still see disposition=inline. MIME type matching is case-insensitive
per RFC 2045 §5.1 and may carry parameters, so the safe thing is to
normalize at the boundary instead of trusting every caller.

Today both call sites (S3.Upload and LocalStorage.Serve) happen to
feed in the exact literal because the upload handler overrides .svg
to "image/svg+xml" before storage sees it, so this is defense-in-depth
rather than a live regression. Hardens the helper so any future caller
(including one that ever trusts a client-supplied Content-Type) stays
behind the same guard.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:01:52 +08:00

49 lines
1.7 KiB
Go

package storage
import (
"strings"
)
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
func sanitizeFilename(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// isInlineContentType returns true for media types that browsers should
// display inline (images, video, audio, PDF). Everything else triggers a
// download via Content-Disposition: attachment.
//
// SVG is excluded even though its MIME type is image/svg+xml: SVG is XML
// and can carry <script>, <foreignObject>, or onload= attributes that
// execute in the document's origin when rendered inline. Forcing
// attachment disposition prevents stored-XSS via uploaded .svg files.
//
// Input is normalized (trim, lowercase, strip parameters) before matching
// so that values like "image/svg+xml; charset=utf-8" or "IMAGE/SVG+XML"
// can't slip past the SVG carve-out. RFC 2045 §5.1 defines MIME type
// matching as case-insensitive with optional parameters; this is the
// security boundary, so normalize here instead of trusting callers.
func isInlineContentType(ct string) bool {
mediaType := strings.ToLower(strings.TrimSpace(ct))
if i := strings.IndexByte(mediaType, ';'); i >= 0 {
mediaType = strings.TrimSpace(mediaType[:i])
}
if mediaType == "image/svg+xml" {
return false
}
return strings.HasPrefix(mediaType, "image/") ||
strings.HasPrefix(mediaType, "video/") ||
strings.HasPrefix(mediaType, "audio/") ||
mediaType == "application/pdf"
}