6.4 KB
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
}
← Back