feat: Add audit logging, magic bytes validation, per-user quotas, and admin API
All checks were successful
CI/CD / Test (push) Successful in 31s
CI/CD / Lint (push) Successful in 42s
CI/CD / Generate SBOM (push) Successful in 17s
CI/CD / Build (darwin-amd64) (push) Successful in 22s
CI/CD / Build (linux-amd64) (push) Successful in 22s
CI/CD / Build (darwin-arm64) (push) Successful in 23s
CI/CD / Build (linux-arm64) (push) Successful in 22s
CI/CD / Build & Push Docker Image (push) Successful in 22s
CI/CD / Mirror to GitHub (push) Successful in 16s
CI/CD / Release (push) Has been skipped
All checks were successful
CI/CD / Test (push) Successful in 31s
CI/CD / Lint (push) Successful in 42s
CI/CD / Generate SBOM (push) Successful in 17s
CI/CD / Build (darwin-amd64) (push) Successful in 22s
CI/CD / Build (linux-amd64) (push) Successful in 22s
CI/CD / Build (darwin-arm64) (push) Successful in 23s
CI/CD / Build (linux-arm64) (push) Successful in 22s
CI/CD / Build & Push Docker Image (push) Successful in 22s
CI/CD / Mirror to GitHub (push) Successful in 16s
CI/CD / Release (push) Has been skipped
New features in v3.3.0: - audit.go: Security audit logging with JSON/text format, log rotation - validation.go: Magic bytes content validation with wildcard patterns - quota.go: Per-user storage quotas with Redis/memory tracking - admin.go: Admin API for stats, file management, user quotas, bans Integration: - Updated main.go with feature initialization and handler integration - Added audit logging for auth success/failure, uploads, downloads - Added quota checking before upload, tracking after successful upload - Added content validation with magic bytes detection Config: - New template: config-enhanced-features.toml with all new options - Updated README.md with feature documentation
This commit is contained in:
340
cmd/server/validation.go
Normal file
340
cmd/server/validation.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// validation.go - Content type validation using magic bytes
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ValidationConfig holds content validation configuration
|
||||
type ValidationConfig struct {
|
||||
CheckMagicBytes bool `toml:"check_magic_bytes" mapstructure:"check_magic_bytes"`
|
||||
AllowedTypes []string `toml:"allowed_types" mapstructure:"allowed_types"`
|
||||
BlockedTypes []string `toml:"blocked_types" mapstructure:"blocked_types"`
|
||||
MaxFileSize string `toml:"max_file_size" mapstructure:"max_file_size"`
|
||||
StrictMode bool `toml:"strict_mode" mapstructure:"strict_mode"` // Reject if type can't be detected
|
||||
}
|
||||
|
||||
// ValidationResult contains the result of content validation
|
||||
type ValidationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
DetectedType string `json:"detected_type"`
|
||||
DeclaredType string `json:"declared_type,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ValidationError represents a validation failure
|
||||
type ValidationError struct {
|
||||
Code string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
DetectedType string `json:"detected_type"`
|
||||
DeclaredType string `json:"declared_type,omitempty"`
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ContentValidator handles content type validation
|
||||
type ContentValidator struct {
|
||||
config *ValidationConfig
|
||||
allowedTypes map[string]bool
|
||||
blockedTypes map[string]bool
|
||||
wildcardAllow []string
|
||||
wildcardBlock []string
|
||||
}
|
||||
|
||||
var (
|
||||
contentValidator *ContentValidator
|
||||
validatorOnce sync.Once
|
||||
)
|
||||
|
||||
// InitContentValidator initializes the content validator
|
||||
func InitContentValidator(config *ValidationConfig) {
|
||||
validatorOnce.Do(func() {
|
||||
contentValidator = &ContentValidator{
|
||||
config: config,
|
||||
allowedTypes: make(map[string]bool),
|
||||
blockedTypes: make(map[string]bool),
|
||||
wildcardAllow: []string{},
|
||||
wildcardBlock: []string{},
|
||||
}
|
||||
|
||||
// Process allowed types
|
||||
for _, t := range config.AllowedTypes {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if strings.HasSuffix(t, "/*") {
|
||||
contentValidator.wildcardAllow = append(contentValidator.wildcardAllow, strings.TrimSuffix(t, "/*"))
|
||||
} else {
|
||||
contentValidator.allowedTypes[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Process blocked types
|
||||
for _, t := range config.BlockedTypes {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if strings.HasSuffix(t, "/*") {
|
||||
contentValidator.wildcardBlock = append(contentValidator.wildcardBlock, strings.TrimSuffix(t, "/*"))
|
||||
} else {
|
||||
contentValidator.blockedTypes[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("Content validator initialized: magic_bytes=%v, allowed=%d types, blocked=%d types",
|
||||
config.CheckMagicBytes, len(config.AllowedTypes), len(config.BlockedTypes))
|
||||
})
|
||||
}
|
||||
|
||||
// GetContentValidator returns the singleton content validator
|
||||
func GetContentValidator() *ContentValidator {
|
||||
return contentValidator
|
||||
}
|
||||
|
||||
// isTypeAllowed checks if a content type is in the allowed list
|
||||
func (v *ContentValidator) isTypeAllowed(contentType string) bool {
|
||||
contentType = strings.ToLower(contentType)
|
||||
|
||||
// Extract main type (before any parameters like charset)
|
||||
if idx := strings.Index(contentType, ";"); idx != -1 {
|
||||
contentType = strings.TrimSpace(contentType[:idx])
|
||||
}
|
||||
|
||||
// If no allowed types configured, allow all (except blocked)
|
||||
if len(v.allowedTypes) == 0 && len(v.wildcardAllow) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check exact match
|
||||
if v.allowedTypes[contentType] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check wildcard patterns
|
||||
for _, prefix := range v.wildcardAllow {
|
||||
if strings.HasPrefix(contentType, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isTypeBlocked checks if a content type is in the blocked list
|
||||
func (v *ContentValidator) isTypeBlocked(contentType string) bool {
|
||||
contentType = strings.ToLower(contentType)
|
||||
|
||||
// Extract main type (before any parameters)
|
||||
if idx := strings.Index(contentType, ";"); idx != -1 {
|
||||
contentType = strings.TrimSpace(contentType[:idx])
|
||||
}
|
||||
|
||||
// Check exact match
|
||||
if v.blockedTypes[contentType] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check wildcard patterns
|
||||
for _, prefix := range v.wildcardBlock {
|
||||
if strings.HasPrefix(contentType, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateContent validates the content type of a reader
|
||||
// Returns a new reader that includes the buffered bytes, the detected type, and any error
|
||||
func (v *ContentValidator) ValidateContent(reader io.Reader, declaredType string, size int64) (io.Reader, string, error) {
|
||||
if v == nil || !v.config.CheckMagicBytes {
|
||||
return reader, declaredType, nil
|
||||
}
|
||||
|
||||
// Read first 512 bytes for magic byte detection
|
||||
buf := make([]byte, 512)
|
||||
n, err := io.ReadFull(reader, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return nil, "", fmt.Errorf("failed to read content for validation: %w", err)
|
||||
}
|
||||
|
||||
// Handle small files
|
||||
if n == 0 {
|
||||
if v.config.StrictMode {
|
||||
return nil, "", &ValidationError{
|
||||
Code: "empty_content",
|
||||
Message: "Cannot validate empty content",
|
||||
DetectedType: "",
|
||||
DeclaredType: declaredType,
|
||||
}
|
||||
}
|
||||
return bytes.NewReader(buf[:n]), declaredType, nil
|
||||
}
|
||||
|
||||
// Detect content type using magic bytes
|
||||
detectedType := http.DetectContentType(buf[:n])
|
||||
|
||||
// Normalize detected type
|
||||
if idx := strings.Index(detectedType, ";"); idx != -1 {
|
||||
detectedType = strings.TrimSpace(detectedType[:idx])
|
||||
}
|
||||
|
||||
// Check if type is blocked (highest priority)
|
||||
if v.isTypeBlocked(detectedType) {
|
||||
return nil, detectedType, &ValidationError{
|
||||
Code: "content_type_blocked",
|
||||
Message: fmt.Sprintf("File type %s is blocked", detectedType),
|
||||
DetectedType: detectedType,
|
||||
DeclaredType: declaredType,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if type is allowed
|
||||
if !v.isTypeAllowed(detectedType) {
|
||||
return nil, detectedType, &ValidationError{
|
||||
Code: "content_type_rejected",
|
||||
Message: fmt.Sprintf("File type %s is not allowed", detectedType),
|
||||
DetectedType: detectedType,
|
||||
DeclaredType: declaredType,
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new reader that includes the buffered bytes
|
||||
combinedReader := io.MultiReader(bytes.NewReader(buf[:n]), reader)
|
||||
|
||||
return combinedReader, detectedType, nil
|
||||
}
|
||||
|
||||
// ValidateContentType validates a content type without reading content
|
||||
func (v *ContentValidator) ValidateContentType(contentType string) error {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if v.isTypeBlocked(contentType) {
|
||||
return &ValidationError{
|
||||
Code: "content_type_blocked",
|
||||
Message: fmt.Sprintf("File type %s is blocked", contentType),
|
||||
DetectedType: contentType,
|
||||
}
|
||||
}
|
||||
|
||||
if !v.isTypeAllowed(contentType) {
|
||||
return &ValidationError{
|
||||
Code: "content_type_rejected",
|
||||
Message: fmt.Sprintf("File type %s is not allowed", contentType),
|
||||
DetectedType: contentType,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteValidationError writes a validation error response
|
||||
func WriteValidationError(w http.ResponseWriter, err *ValidationError) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
_ = json.NewEncoder(w).Encode(err)
|
||||
}
|
||||
|
||||
// ValidateUploadContent is a helper function for validating upload content
|
||||
func ValidateUploadContent(r *http.Request, reader io.Reader, declaredType string, size int64) (io.Reader, string, error) {
|
||||
validator := GetContentValidator()
|
||||
if validator == nil || !validator.config.CheckMagicBytes {
|
||||
return reader, declaredType, nil
|
||||
}
|
||||
|
||||
newReader, detectedType, err := validator.ValidateContent(reader, declaredType, size)
|
||||
if err != nil {
|
||||
// Log validation failure to audit
|
||||
jid := r.Header.Get("X-User-JID")
|
||||
fileName := r.Header.Get("X-File-Name")
|
||||
if fileName == "" {
|
||||
fileName = "unknown"
|
||||
}
|
||||
|
||||
var reason string
|
||||
if validErr, ok := err.(*ValidationError); ok {
|
||||
reason = validErr.Code
|
||||
} else {
|
||||
reason = err.Error()
|
||||
}
|
||||
|
||||
AuditValidationFailure(r, jid, fileName, declaredType, detectedType, reason)
|
||||
|
||||
return nil, detectedType, err
|
||||
}
|
||||
|
||||
return newReader, detectedType, nil
|
||||
}
|
||||
|
||||
// DefaultValidationConfig returns default validation configuration
|
||||
func DefaultValidationConfig() ValidationConfig {
|
||||
return ValidationConfig{
|
||||
CheckMagicBytes: false,
|
||||
AllowedTypes: []string{
|
||||
"image/*",
|
||||
"video/*",
|
||||
"audio/*",
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"text/html",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/zip",
|
||||
"application/x-gzip",
|
||||
"application/x-tar",
|
||||
"application/x-7z-compressed",
|
||||
"application/vnd.openxmlformats-officedocument.*",
|
||||
"application/vnd.oasis.opendocument.*",
|
||||
},
|
||||
BlockedTypes: []string{
|
||||
"application/x-executable",
|
||||
"application/x-msdos-program",
|
||||
"application/x-msdownload",
|
||||
"application/x-dosexec",
|
||||
"application/x-sh",
|
||||
"application/x-shellscript",
|
||||
},
|
||||
MaxFileSize: "100MB",
|
||||
StrictMode: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Extended MIME type detection for better accuracy
|
||||
var customMagicBytes = map[string][]byte{
|
||||
"application/x-executable": {0x7f, 'E', 'L', 'F'}, // ELF
|
||||
"application/x-msdos-program": {0x4d, 0x5a}, // MZ (DOS/Windows)
|
||||
"application/pdf": {0x25, 0x50, 0x44, 0x46}, // %PDF
|
||||
"application/zip": {0x50, 0x4b, 0x03, 0x04}, // PK
|
||||
"image/png": {0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, // PNG
|
||||
"image/jpeg": {0xff, 0xd8, 0xff}, // JPEG
|
||||
"image/gif": {0x47, 0x49, 0x46, 0x38}, // GIF8
|
||||
"image/webp": {0x52, 0x49, 0x46, 0x46}, // RIFF (WebP starts with RIFF)
|
||||
"video/mp4": {0x00, 0x00, 0x00}, // MP4 (variable, check ftyp)
|
||||
"audio/mpeg": {0xff, 0xfb}, // MP3
|
||||
"audio/ogg": {0x4f, 0x67, 0x67, 0x53}, // OggS
|
||||
}
|
||||
|
||||
// DetectContentTypeExtended provides extended content type detection
|
||||
func DetectContentTypeExtended(data []byte) string {
|
||||
// First try standard detection
|
||||
detected := http.DetectContentType(data)
|
||||
|
||||
// If generic, try custom detection
|
||||
if detected == "application/octet-stream" {
|
||||
for mimeType, magic := range customMagicBytes {
|
||||
if len(data) >= len(magic) && bytes.Equal(data[:len(magic)], magic) {
|
||||
return mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detected
|
||||
}
|
||||
Reference in New Issue
Block a user