chore: import clean Agent Desk snapshot (3/7)
This commit is contained in:
218
internal/services/storage/safety.go
Normal file
218
internal/services/storage/safety.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user