diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index d5fc669a..30d214a6 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -3,6 +3,7 @@ package bootstrap import ( "log/slog" "net/http" + "path" "strconv" "strings" "time" @@ -17,6 +18,7 @@ import ( "agent-desk/internal/pkg/i18nx" "agent-desk/internal/pkg/tracex" "agent-desk/internal/services" + "agent-desk/internal/services/storage" webspa "agent-desk/web" "github.com/gin-gonic/gin" @@ -44,11 +46,29 @@ func NewServer() (*gin.Engine, error) { handleSpa(app) - app.StaticFS(cfg.Storage.Local.BaseURL, ginx.StaticFiles(cfg.Storage.Local.Root)) + storageGroup := app.Group(cfg.Storage.Local.BaseURL, assetResponseHeaders()) + storageGroup.StaticFS("", ginx.StaticFiles(cfg.Storage.Local.Root)) return app, nil } +// assetResponseHeaders guards the locally stored assets. +// +// Those files are user-supplied bytes served from this application's own origin, +// so a response a browser renders inline is same-origin content. nosniff stops a +// browser reinterpreting the payload, and forcing a download for anything that is +// not previewable media means a document type that slipped in before this policy +// existed still cannot run as a page. +func assetResponseHeaders() gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Header("X-Content-Type-Options", "nosniff") + if !storage.IsPreviewableExtension(path.Ext(ctx.Request.URL.Path)) { + ctx.Header("Content-Disposition", "attachment") + } + ctx.Next() + } +} + func corsMiddleware() gin.HandlerFunc { allowedOrigins := config.Current().Server.CORS.AllowedOrigins allowHeaders := "Origin, Content-Type, Accept, Authorization, X-Requested-With, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At" diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 328b293f..0b2716e4 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -296,6 +296,64 @@ func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) { } } +func TestNewServerHardensStoredAssetResponses(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"screenshot.png", "archive.zip", "legacy-page.html"} { + if err := os.WriteFile(filepath.Join(root, name), []byte("payload"), 0o644); err != nil { + t.Fatalf("WriteFile(%s) error = %v", name, err) + } + } + + config.SetCurrent(&config.Config{ + Storage: config.StorageConfig{ + Local: config.LocalStorageConfig{ + Root: root, + BaseURL: "/storage", + }, + }, + }) + + app, err := NewServer() + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + tests := []struct { + path string + wantStatus int + contentType string + wantAttachment bool + }{ + {path: "/storage/screenshot.png", wantStatus: http.StatusOK, contentType: "image/png"}, + {path: "/storage/archive.zip", wantStatus: http.StatusOK, wantAttachment: true}, + // A file planted before the upload policy existed must still not render. + {path: "/storage/legacy-page.html", wantStatus: http.StatusOK, wantAttachment: true}, + {path: "/storage/missing.png", wantStatus: http.StatusNotFound}, + } + + for _, tt := range tests { + rec := httptest.NewRecorder() + app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) + + if rec.Code != tt.wantStatus { + t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("%s X-Content-Type-Options=%q want nosniff", tt.path, got) + } + if tt.contentType != "" && !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) { + t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType) + } + got := rec.Header().Get("Content-Disposition") + if tt.wantAttachment && got != "attachment" { + t.Fatalf("%s Content-Disposition=%q want attachment", tt.path, got) + } + if !tt.wantAttachment && got != "" { + t.Fatalf("%s Content-Disposition=%q want empty so the asset renders inline", tt.path, got) + } + } +} + func TestNewServerAllowsConfiguredCORSOrigin(t *testing.T) { config.SetCurrent(&config.Config{ Server: config.ServerConfig{ diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index 9fbec600..e74b751b 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -345,6 +345,7 @@ error.e0344: "Invalid attachment message payload format." error.e0345: "Attachment message is missing assetId." error.e0346: "Attachment message is missing payload." error.e0347: "Default team queue mode requires at least one agent team." +error.e0348: "This file type cannot be uploaded for security reasons." error.profile.nicknameRequired: "Enter a nickname." error.profile.nicknameTooLong: "Nickname cannot exceed 100 characters." error.profile.avatarTooLong: "Avatar link cannot exceed 255 characters." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 6b3aa8eb..371264f0 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -345,6 +345,7 @@ error.e0344: "附件消息 payload 格式错误" error.e0345: "附件消息缺少 assetId" error.e0346: "附件消息缺少 payload" error.e0347: "默认客服组待接入池模式必须至少选择一个客服组" +error.e0348: "出于安全考虑,此文件类型不支持上传" error.profile.nicknameRequired: "请输入昵称" error.profile.nicknameTooLong: "昵称不能超过 100 个字符" error.profile.avatarTooLong: "头像链接不能超过 255 个字符" diff --git a/internal/services/asset_service.go b/internal/services/asset_service.go index b7596beb..aaf9a71c 100644 --- a/internal/services/asset_service.go +++ b/internal/services/asset_service.go @@ -61,12 +61,16 @@ func (s *assetService) OpenReader(asset *models.Asset) (io.ReadCloser, error) { } func (s *assetService) UploadBytes(data []byte, prefix, filename string, principal *dto.AuthPrincipal) (*models.Asset, error) { - src := bytes.NewReader(data) - return s.Upload(src, storage.UploadInfo{ + filename = storage.SanitizeFilename(filename) + mimeType, err := storage.ValidateUpload(filename, "", http.DetectContentType(data)) + if err != nil { + return nil, err + } + return s.Upload(bytes.NewReader(data), storage.UploadInfo{ Prefix: prefix, Filename: filename, FileSize: int64(len(data)), - MimeType: http.DetectContentType(data), + MimeType: mimeType, Principal: principal, }) } @@ -87,11 +91,22 @@ func (s *assetService) UploadFile(file *multipart.FileHeader, prefix string, pri } defer func() { _ = src.Close() }() + sniffed, err := storage.SniffContentType(src) + if err != nil { + return nil, err + } + + filename := storage.SanitizeFilename(file.Filename) + mimeType, err := storage.ValidateUpload(filename, file.Header.Get("Content-Type"), sniffed) + if err != nil { + return nil, err + } + return s.Upload(src, storage.UploadInfo{ Prefix: prefix, - Filename: file.Filename, + Filename: filename, FileSize: file.Size, - MimeType: file.Header.Get("Content-Type"), + MimeType: mimeType, Principal: principal, }) } diff --git a/internal/services/storage/safety.go b/internal/services/storage/safety.go new file mode 100644 index 00000000..7b9cc342 --- /dev/null +++ b/internal/services/storage/safety.go @@ -0,0 +1,218 @@ +package storage + +import ( + "io" + "mime" + "net/http" + "path" + "strings" + "unicode/utf8" + + "agent-desk/internal/pkg/errorsx" +) + +const ( + // sniffLimit is the number of leading bytes net/http.DetectContentType inspects. + sniffLimit = 512 + // maxFilenameLength keeps a stored filename inside the column that holds it. + maxFilenameLength = 255 +) + +// previewableExtensions are the only stored file types a browser may render +// inline. Every other extension is forced to download. +// +// Assets are served from this application's own origin, so an inline response is +// same-origin content: a browser that navigates to it runs whatever the bytes +// ask for, in the same security context as the dashboard and the public support +// widget. Forcing a download for anything we have not vetted keeps that true +// only for media, which cannot touch the DOM. +var previewableExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, + ".bmp": true, ".ico": true, ".avif": true, ".tif": true, ".tiff": true, + ".heic": true, ".heif": true, + ".mp4": true, ".m4v": true, ".webm": true, ".mov": true, ".ogv": true, + ".mp3": true, ".m4a": true, ".wav": true, ".ogg": true, ".oga": true, + ".aac": true, ".flac": true, + ".pdf": true, +} + +// blockedExtensions are rejected at upload time. Each one names a document a +// browser executes or lays out instead of displaying, so accepting it would let +// an unauthenticated visitor plant a script that runs against this origin the +// moment a staff member opens the attachment link. +var blockedExtensions = map[string]bool{ + ".html": true, ".htm": true, ".shtml": true, ".xhtml": true, ".xht": true, + ".svg": true, ".svgz": true, + ".xml": true, ".xsl": true, ".xslt": true, ".xsd": true, ".wsdl": true, + ".js": true, ".mjs": true, ".cjs": true, + ".swf": true, + ".php": true, ".phtml": true, ".php3": true, ".php4": true, ".php5": true, + ".asp": true, ".aspx": true, ".jsp": true, ".jspx": true, ".cfm": true, + ".hta": true, ".htc": true, ".htaccess": true, +} + +// blockedMediaTypes are the payloads a browser renders as an active document. +// They are rejected regardless of the extension the client chose, so renaming a +// page to .png does not smuggle it past the extension check. +var blockedMediaTypes = map[string]bool{ + "text/html": true, + "application/xhtml+xml": true, + "image/svg+xml": true, + "text/xml": true, + "application/xml": true, + "application/xslt+xml": true, + "text/javascript": true, + "application/javascript": true, + "application/x-javascript": true, +} + +// blockedUploadI18nKey is returned to the uploader whenever the file-safety +// policy rejects a payload. It deliberately does not name the offending type. +const blockedUploadI18nKey = "error.e0348" + +// mediaTypeExtensions maps a MIME type to the extension it should be stored +// under. mime.ExtensionsByType cannot be used here: on Windows it consults the +// registry, so the same type resolves to a different extension than in +// production, and it happily hands back an extension that describes an active +// document. Types that must never become a servable document are absent, which +// leaves the caller with the inert .bin fallback. +var mediaTypeExtensions = map[string]string{ + // images + "image/jpeg": ".jpg", "image/jfif": ".jpg", "image/pjpeg": ".jpg", + "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", + "image/bmp": ".bmp", "image/tiff": ".tiff", "image/avif": ".avif", + "image/heic": ".heic", "image/heif": ".heif", "image/x-icon": ".ico", + "image/vnd.microsoft.icon": ".ico", + // documents + "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", + "text/markdown": ".md", "application/json": ".json", "application/rtf": ".rtf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/vnd.oasis.opendocument.text": ".odt", + "application/vnd.oasis.opendocument.spreadsheet": ".ods", + "application/vnd.oasis.opendocument.presentation": ".odp", + // archives + "application/zip": ".zip", "application/x-zip-compressed": ".zip", + "application/x-rar-compressed": ".rar", "application/vnd.rar": ".rar", + "application/x-7z-compressed": ".7z", "application/x-tar": ".tar", + "application/gzip": ".gz", "application/x-bzip2": ".bz2", "application/x-xz": ".xz", + // audio + "audio/mpeg": ".mp3", "audio/mp4": ".m4a", "audio/x-m4a": ".m4a", + "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/ogg": ".ogg", + "audio/aac": ".aac", "audio/flac": ".flac", "audio/webm": ".webm", + // video + "video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov", + "video/x-msvideo": ".avi", "video/x-matroska": ".mkv", "video/ogg": ".ogv", + "video/mpeg": ".mpeg", +} + +// safeExtensionForMediaType returns the extension a payload of this type should +// be stored under, or "" when the type is not one this origin is willing to +// serve as a document. +func safeExtensionForMediaType(mediaType string) string { + return mediaTypeExtensions[strings.ToLower(strings.TrimSpace(mediaType))] +} + +// IsPreviewableExtension reports whether a stored file may be rendered inline. +func IsPreviewableExtension(ext string) bool { + return previewableExtensions[normalizeExt(ext)] +} + +// IsBlockedExtension reports whether an extension names a browser-active document. +func IsBlockedExtension(ext string) bool { + return blockedExtensions[normalizeExt(ext)] +} + +// IsBlockedMediaType reports whether a MIME type describes a browser-active document. +func IsBlockedMediaType(mediaType string) bool { + parsed, _, err := mime.ParseMediaType(strings.TrimSpace(mediaType)) + if err != nil || parsed == "" { + return false + } + return blockedMediaTypes[strings.ToLower(parsed)] +} + +// SanitizeFilename reduces a client-supplied name to a bare basename. Uploads +// arrive from browsers, mobile SDKs and channel webhooks, all of which are free +// to send a full path, control characters or nothing at all; the result is stored +// on the asset and echoed back into message payloads and download links. +func SanitizeFilename(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + name = path.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, name) + name = strings.TrimSpace(strings.TrimRight(name, ". ")) + if len(name) > maxFilenameLength { + ext := path.Ext(name) + if len(ext) > maxFilenameLength { + ext = "" + } + stem := strings.TrimSuffix(name, ext) + // Cut on a rune boundary: a partial multibyte character would not survive + // the round trip through a utf8mb4 column. + limit := maxFilenameLength - len(ext) + for limit > 0 && !utf8.RuneStart(stem[limit]) { + limit-- + } + name = stem[:limit] + ext + } + return name +} + +// SniffContentType reports what the leading bytes of a seekable payload actually +// are, then rewinds so the caller can still stream the whole file to storage. +// The Content-Type a client declares is a claim, not evidence. +func SniffContentType(src io.ReadSeeker) (string, error) { + head := make([]byte, sniffLimit) + read, err := io.ReadFull(src, head) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return "", err + } + if _, err := src.Seek(0, io.SeekStart); err != nil { + return "", err + } + return http.DetectContentType(head[:read]), nil +} + +// ValidateUpload applies the file-safety policy to an upload and returns the MIME +// type that should be recorded on the asset. +// +// sniffed comes from SniffContentType and wins whenever it identified something +// specific; declared is what the client claimed and is only trusted for the +// formats net/http cannot recognise, such as HEIC and most Office documents. +func ValidateUpload(filename, declared, sniffed string) (string, error) { + if IsBlockedExtension(path.Ext(filename)) { + return "", errorsx.InvalidParamI18n(blockedUploadI18nKey) + } + if IsBlockedMediaType(sniffed) || IsBlockedMediaType(declared) { + return "", errorsx.InvalidParamI18n(blockedUploadI18nKey) + } + + mediaType, _, _ := mime.ParseMediaType(sniffed) + if mediaType != "" && mediaType != "application/octet-stream" && !strings.HasPrefix(mediaType, "text/") { + return sniffed, nil + } + if declared != "" { + return declared, nil + } + return sniffed, nil +} + +func normalizeExt(ext string) string { + ext = strings.ToLower(strings.TrimSpace(ext)) + if ext != "" && !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return ext +} diff --git a/internal/services/storage/safety_test.go b/internal/services/storage/safety_test.go new file mode 100644 index 00000000..78fafbe6 --- /dev/null +++ b/internal/services/storage/safety_test.go @@ -0,0 +1,268 @@ +package storage + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + "unicode/utf8" +) + +var pngSignature = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR") + +func TestValidateUploadBlocksBrowserActiveExtensions(t *testing.T) { + names := []string{ + "session-stealer.html", "page.HTM", "vector.svg", "sheet.xml", + "transform.xsl", "bundle.min.js", "app.mjs", "shell.php", + "report.asp", "index.jsp", "legacy.swf", "widget.htc", + } + for _, name := range names { + if _, err := ValidateUpload(name, "application/octet-stream", "application/octet-stream"); err == nil { + t.Errorf("ValidateUpload(%q) accepted a browser-active extension", name) + } + } +} + +func TestValidateUploadBlocksStoredXSSPayload(t *testing.T) { + payload := []byte(``) + sniffed, err := SniffContentType(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if !IsBlockedMediaType(sniffed) { + t.Fatalf("expected the payload to sniff as a blocked media type, got %q", sniffed) + } + + if _, err := ValidateUpload("proof.html", "text/html", sniffed); err == nil { + t.Error("expected an honestly named HTML upload to be rejected") + } + // Renaming the payload is the actual attack: the extension looks like an + // image and the declared type agrees, so only the bytes can catch it. + if _, err := ValidateUpload("profile.png", "image/png", sniffed); err == nil { + t.Error("expected an HTML payload disguised as a PNG to be rejected") + } +} + +func TestValidateUploadBlocksSVGRegardlessOfDeclaredType(t *testing.T) { + payload := []byte(``) + sniffed, err := SniffContentType(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if _, err := ValidateUpload("logo.svg", "image/svg+xml", sniffed); err == nil { + t.Error("expected an SVG upload to be rejected") + } + // An SVG that arrives without the .svg extension is still refused, because + // the declared type alone is enough to identify it as an active document. + if _, err := ValidateUpload("logo.png", "image/svg+xml", sniffed); err == nil { + t.Error("expected an SVG declared as an image to be rejected") + } +} + +func TestValidateUploadAcceptsOrdinarySupportFiles(t *testing.T) { + cases := []struct { + filename string + declared string + sniffed string + wantMime string + }{ + {"screenshot.png", "image/png", http.DetectContentType(pngSignature), "image/png"}, + // The client declared a generic type; the payload identified itself. + {"photo.jpg", "application/octet-stream", "image/jpeg", "image/jpeg"}, + // net/http cannot recognise HEIC, so the declared type has to stand in. + {"IMG_0001.heic", "image/heic", "application/octet-stream", "image/heic"}, + {"contract.pdf", "application/pdf", "application/pdf", "application/pdf"}, + {"export.csv", "text/csv", "text/plain; charset=utf-8", "text/csv"}, + {"notes.txt", "text/plain", "text/plain; charset=utf-8", "text/plain"}, + {"logs.zip", "application/zip", "application/zip", "application/zip"}, + {"build.apk", "application/vnd.android.package-archive", "application/octet-stream", "application/vnd.android.package-archive"}, + {"data.json", "application/json", "application/json", "application/json"}, + } + for _, tc := range cases { + got, err := ValidateUpload(tc.filename, tc.declared, tc.sniffed) + if err != nil { + t.Errorf("ValidateUpload(%q) error = %v", tc.filename, err) + continue + } + if got != tc.wantMime { + t.Errorf("ValidateUpload(%q) mime = %q want %q", tc.filename, got, tc.wantMime) + } + } +} + +func TestSanitizeFilename(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"report.pdf", "report.pdf"}, + {"../../etc/passwd", "passwd"}, + {`C:\Users\victim\Desktop\evil.html`, "evil.html"}, + {"/absolute/path/notes.txt", "notes.txt"}, + {"tab\tand\nnewline.log", "tabandnewline.log"}, + {"....", ""}, + {"", ""}, + {" spaced name.pdf ", "spaced name.pdf"}, + } + for _, tc := range cases { + if got := SanitizeFilename(tc.in); got != tc.want { + t.Errorf("SanitizeFilename(%q) = %q want %q", tc.in, got, tc.want) + } + } + + long := strings.Repeat("a", 300) + ".pdf" + got := SanitizeFilename(long) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !strings.HasSuffix(got, ".pdf") { + t.Errorf("SanitizeFilename() = %q, expected the extension to survive truncation", got) + } + + // A multibyte stem must not be cut in the middle of a rune. + wide := strings.Repeat("附件", 100) + ".pdf" + got = SanitizeFilename(wide) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("SanitizeFilename() = %q is not valid UTF-8", got) + } + if !strings.HasSuffix(got, ".pdf") { + t.Errorf("SanitizeFilename() = %q, expected the extension to survive truncation", got) + } + + // An extension longer than the whole budget cannot be preserved. + got = SanitizeFilename("a." + strings.Repeat("b", 300)) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("SanitizeFilename() = %q is not valid UTF-8", got) + } + + // A name made entirely of dots leaves nothing worth keeping. + if got = SanitizeFilename(strings.Repeat(".", 300)); got != "" { + t.Errorf("SanitizeFilename() of a dot-only name = %q want empty", got) + } +} + +func TestSniffContentTypeRewindsTheReader(t *testing.T) { + src := bytes.NewReader(pngSignature) + got, err := SniffContentType(src) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if got != "image/png" { + t.Fatalf("SniffContentType() = %q want image/png", got) + } + + // The caller still streams the whole payload to storage afterwards, so the + // reader must be back at the start. + rest, err := io.ReadAll(src) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if !bytes.Equal(rest, pngSignature) { + t.Fatalf("reader was not rewound: got %d bytes want %d", len(rest), len(pngSignature)) + } +} + +func TestSniffContentTypeHandlesShortAndEmptyPayloads(t *testing.T) { + short := bytes.NewReader([]byte("hi")) + got, err := SniffContentType(short) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if !strings.HasPrefix(got, "text/plain") { + t.Fatalf("SniffContentType() = %q want a text/plain result", got) + } + rest, err := io.ReadAll(short) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if string(rest) != "hi" { + t.Fatalf("reader was not rewound: got %q", rest) + } + + empty := bytes.NewReader(nil) + if _, err := SniffContentType(empty); err != nil { + t.Fatalf("SniffContentType() on an empty payload error = %v", err) + } +} + +func TestGenerateStorageKeyNeverUsesBlockedExtension(t *testing.T) { + cases := []struct { + info UploadInfo + want string + }{ + {UploadInfo{Filename: "evil.html", MimeType: "text/html"}, ".bin"}, + {UploadInfo{Filename: "evil.svg", MimeType: "image/svg+xml"}, ".bin"}, + // The extension is gone but the MIME type still names an active + // document, so the derived extension must be refused too. + {UploadInfo{Filename: "noext", MimeType: "text/html"}, ".bin"}, + {UploadInfo{Filename: "notes.txt", MimeType: "text/plain"}, ".txt"}, + {UploadInfo{Filename: "photo.png", MimeType: "image/png"}, ".png"}, + {UploadInfo{Filename: "unknown", MimeType: "application/octet-stream"}, ".bin"}, + } + for _, tc := range cases { + _, key := GenerateStorageKey(tc.info) + if !strings.HasSuffix(key, tc.want) { + t.Errorf("GenerateStorageKey(%+v) = %q, expected it to end in %q", tc.info, key, tc.want) + } + if strings.Contains(key, "..") { + t.Errorf("GenerateStorageKey(%+v) = %q contains a traversal segment", tc.info, key) + } + } +} + +func TestGetExtByMimeTypeIsPlatformIndependent(t *testing.T) { + // mime.ExtensionsByType reads the Windows registry, where text/html resolved + // to ".ehtml" and slipped past the block list. The mapping has to come from + // our own table so a storage key is the same on every platform, and so a + // media type that describes an active document maps to nothing at all. + cases := map[string]string{ + "text/html": "", + "application/xhtml+xml": "", + "image/svg+xml": "", + "text/xml": "", + "application/xml": "", + "application/javascript": "", + "text/javascript": "", + "image/jpeg": ".jpg", + "image/jfif": ".jpg", + "image/pjpeg": ".jpg", + "image/png": ".png", + "application/pdf": ".pdf", + "text/csv": ".csv", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + } + for mediaType, want := range cases { + if got := getExtByMimeType(mediaType); got != want { + t.Errorf("getExtByMimeType(%q) = %q want %q", mediaType, got, want) + } + } + + for mediaType := range blockedMediaTypes { + if got := getExtByMimeType(mediaType); got != "" { + t.Errorf("getExtByMimeType(%q) = %q, a blocked media type must not yield an extension", mediaType, got) + } + } +} + +func TestIsPreviewableExtension(t *testing.T) { + inline := []string{".png", ".JPG", ".jpeg", ".gif", ".webp", ".avif", ".mp4", ".webm", ".mp3", ".pdf"} + for _, ext := range inline { + if !IsPreviewableExtension(ext) { + t.Errorf("IsPreviewableExtension(%q) = false want true", ext) + } + } + + download := []string{".html", ".svg", ".xml", ".js", ".zip", ".txt", ".csv", ".docx", ".apk", ""} + for _, ext := range download { + if IsPreviewableExtension(ext) { + t.Errorf("IsPreviewableExtension(%q) = true want false", ext) + } + } +} diff --git a/internal/services/storage/utils.go b/internal/services/storage/utils.go index 0aabde2e..16b76999 100644 --- a/internal/services/storage/utils.go +++ b/internal/services/storage/utils.go @@ -30,9 +30,16 @@ func GenerateStorageKey(info UploadInfo) (assetID string, storageKey string) { } func getExt(info UploadInfo) string { - ext := strings.ToLower(filepath.Ext(strings.TrimSpace(info.Filename))) - if ext == "" { - ext = getExtByMimeType(info.MimeType) + ext := normalizeExt(filepath.Ext(strings.TrimSpace(info.Filename))) + if ext == "" || IsBlockedExtension(ext) { + ext = normalizeExt(getExtByMimeType(info.MimeType)) + } + if ext == "" || IsBlockedExtension(ext) { + // The stored extension decides the Content-Type this origin serves. A + // browser-active extension must never reach the key, and no extension at + // all would leave the server to sniff the payload and announce whatever + // it finds, so both cases settle on an inert binary type. + return ".bin" } return ext } @@ -47,21 +54,7 @@ func getExtByMimeType(mimeType string) string { return "" } - // 处理一些非标准的 MIME 类型 - switch mediaType { - case "image/jfif": - return ".jpg" - case "image/pjpeg": - return ".jpg" - case "image/jpeg": - return ".jpg" - default: - exts, _ := mime.ExtensionsByType(mediaType) - if len(exts) > 0 { - return exts[0] - } - } - return "" + return safeExtensionForMediaType(mediaType) } func normalizeAssetPrefix(prefix string) string {