file.go
package validate
import (
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
)
// MaxFileSize is the maximum allowed file upload size (10MB).
const MaxFileSize = 10 * 1024 * 1024
// AllowedExtensions is the whitelist of safe file extensions.
var AllowedExtensions = map[string]bool{
// Images
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, ".ico": true, ".bmp": true,
// Documents
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true,
// Text
".txt": true, ".md": true, ".csv": true, ".json": true, ".xml": true, ".html": true, ".css": true, ".js": true,
// Archives
".zip": true, ".tar": true, ".gz": true,
// Audio/Video
".mp3": true, ".wav": true, ".mp4": true, ".webm": true, ".ogg": true,
// Fonts
".woff": true, ".woff2": true, ".ttf": true, ".otf": true, ".eot": true,
}
// BlockedMimeTypes are MIME types that should never be accepted.
var BlockedMimeTypes = map[string]bool{
"application/x-executable": true,
"application/x-msdos-program": true,
"application/x-msdownload": true,
"application/x-sh": true,
"application/x-shellscript": true,
"application/x-php": true,
"application/x-httpd-php": true,
"application/x-python": true,
"application/x-perl": true,
"application/x-ruby": true,
"application/java-archive": true,
"application/x-java-applet": true,
"application/vnd.ms-cab-compressed": true,
}
// extensionMimeMap maps file extensions to expected MIME type prefixes.
var extensionMimeMap = map[string][]string{
".jpg": {"image/jpeg"},
".jpeg": {"image/jpeg"},
".png": {"image/png"},
".gif": {"image/gif"},
".webp": {"image/webp"},
".svg": {"image/svg+xml", "text/xml", "application/xml"},
".ico": {"image/x-icon", "image/vnd.microsoft.icon"},
".bmp": {"image/bmp"},
".pdf": {"application/pdf"},
".html": {"text/html", "text/plain"},
".css": {"text/css", "text/plain"},
".js": {"application/javascript", "text/javascript", "text/plain"},
".json": {"application/json", "text/plain"},
".xml": {"application/xml", "text/xml", "text/plain"},
".txt": {"text/plain"},
".md": {"text/plain", "text/markdown"},
".csv": {"text/csv", "text/plain"},
".mp3": {"audio/mpeg"},
".wav": {"audio/wav", "audio/x-wav"},
".mp4": {"video/mp4"},
".webm": {"video/webm"},
".ogg": {"audio/ogg", "video/ogg"},
".zip": {"application/zip", "application/x-zip-compressed"},
".tar": {"application/x-tar"},
".gz": {"application/gzip", "application/x-gzip"},
".woff": {"font/woff", "application/font-woff"},
".woff2": {"font/woff2"},
".ttf": {"font/ttf", "application/x-font-ttf"},
".otf": {"font/otf", "application/x-font-opentype"},
".eot": {"application/vnd.ms-fontobject"},
".doc": {"application/msword"},
".docx": {"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
".xls": {"application/vnd.ms-excel"},
".xlsx": {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
".ppt": {"application/vnd.ms-powerpoint"},
".pptx": {"application/vnd.openxmlformats-officedocument.presentationml.presentation"},
}
// Upload checks file size, MIME type, and extension.
// Returns an error if validation fails.
func Upload(filename string, size int64, data []byte) error {
// Check file size
if size > MaxFileSize {
return fmt.Errorf("file too large (max %d bytes)", MaxFileSize)
}
// Validate file extension
ext := strings.ToLower(filepath.Ext(filename))
if ext != "" && !AllowedExtensions[ext] {
return fmt.Errorf("file type '%s' is not allowed", ext)
}
// Detect actual MIME type from file content
detectedMime := DetectMimeType(data)
// Block executable and dangerous MIME types
if BlockedMimeTypes[detectedMime] {
return fmt.Errorf("executable files are not allowed")
}
// Validate that file extension matches detected MIME type
if !MimeMatchesExtension(detectedMime, ext) {
return fmt.Errorf("file content does not match extension '%s'", ext)
}
return nil
}
// DetectMimeType detects MIME type from file content.
func DetectMimeType(data []byte) string {
return http.DetectContentType(data)
}
// MimeMatchesExtension checks if the detected MIME type is valid for the extension.
func MimeMatchesExtension(detectedMime, ext string) bool {
// If no extension, we can't validate
if ext == "" {
return true
}
// Check if we have expected MIME types for this extension
expectedMimes, ok := extensionMimeMap[ext]
if !ok {
// Unknown extension - allow it (already validated against AllowedExtensions)
return true
}
// Check if detected MIME matches any expected type
for _, expected := range expectedMimes {
if strings.HasPrefix(detectedMime, expected) {
return true
}
}
// Special case: application/octet-stream is generic and can't be validated
if detectedMime == "application/octet-stream" {
return true
}
return false
}
// SanitizeFilename removes or replaces characters that could cause header injection.
func SanitizeFilename(name string) string {
// Remove any control characters, quotes, and newlines
var result strings.Builder
for _, r := range name {
switch {
case r == '"':
result.WriteRune('\'') // Replace quotes
case r == '\n' || r == '\r' || r == '\x00':
continue // Remove control characters
case r < 32:
continue // Remove other control characters
default:
result.WriteRune(r)
}
}
return result.String()
}
// Path validates a public file path.
// Returns an error if the path is invalid.
func Path(path string) error {
if path == "" {
return nil // Empty path is allowed
}
// Remove leading slash for validation
path = strings.TrimPrefix(path, "/")
// Check for path traversal
if strings.Contains(path, "..") {
return fmt.Errorf("path cannot contain '..'")
}
// Check for invalid characters
validPath := regexp.MustCompile(`^[a-zA-Z0-9_\-./]+$`)
if !validPath.MatchString(path) {
return fmt.Errorf("path contains invalid characters")
}
return nil
}
// GetAllowedMimeTypes returns a list of all allowed MIME types.
func GetAllowedMimeTypes() []string {
mimeSet := make(map[string]bool)
for _, mimes := range extensionMimeMap {
for _, m := range mimes {
mimeSet[m] = true
}
}
result := make([]string, 0, len(mimeSet))
for m := range mimeSet {
result = append(result, m)
}
return result
}