Spaces:
Running
Running
File size: 3,795 Bytes
219cb99 206dd1a 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 219cb99 661db04 99f78e6 65e2f34 219cb99 83ab126 219cb99 83ab126 661db04 99f78e6 219cb99 661db04 219cb99 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | """
File validation utilities for secure file processing.
Why: Prevent malicious files, enforce size limits, validate MIME types.
"""
import magic
from typing import Tuple
from backend.core.config import settings
from backend.core.logger import setup_logger
logger = setup_logger(__name__)
# Extensions that are valid for the allowed MIME types
_ALLOWED_EXTENSIONS = {
"jpg", "jpeg", "png", "webp", "tiff", "tif", "heic", "heif",
"mp4", "mov", "avi", "mkv",
"pdf", "doc", "docx",
}
class FileValidationError(Exception):
"""Custom exception for file validation failures."""
pass
def validate_file_type(file_bytes: bytes, filename: str) -> Tuple[str, str]:
"""
Validate file MIME type using python-magic (reads file signature).
Why python-magic?
- Reads actual file headers, not just extension
- Prevents .exe renamed to .jpg attacks
Args:
file_bytes: Raw file content
filename: Original filename (for extension check)
Returns:
Tuple of (mime_type, file_extension)
Raises:
FileValidationError: If file type not allowed
"""
# Get MIME type from file content (not extension)
mime = magic.Magic(mime=True)
detected_mime = mime.from_buffer(file_bytes)
logger.info(f"File '{filename}' detected as {detected_mime}")
# Combine all allowed types
allowed_types = (
settings.ALLOWED_IMAGE_TYPES +
settings.ALLOWED_VIDEO_TYPES +
settings.ALLOWED_DOC_TYPES
)
if detected_mime not in allowed_types:
raise FileValidationError(
f"File type '{detected_mime}' not allowed. "
f"Allowed: {', '.join(allowed_types)}"
)
# Extract extension
extension = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
return detected_mime, extension
def validate_file_size(file_bytes: bytes, filename: str) -> int:
"""
Validate file size against limit.
Args:
file_bytes: Raw file content
filename: Original filename (for logging)
Returns:
File size in bytes
Raises:
FileValidationError: If file exceeds limit
"""
size_bytes = len(file_bytes)
size_mb = size_bytes / (1024 * 1024)
max_size_mb = settings.MAX_FILE_SIZE_MB
logger.info(f"File '{filename}' size: {size_mb:.2f} MB")
if size_mb > max_size_mb:
raise FileValidationError(
f"File size ({size_mb:.2f} MB) exceeds limit ({max_size_mb} MB)"
)
return size_bytes
def validate_file(file_bytes: bytes, filename: str) -> dict:
"""
Complete file validation (type + size + extension).
extension_valid is enforced (not just computed and reported) --
a MIME-type match with a mismatched extension raises.
"""
# Validate size first (fail fast for DoS protection)
size_bytes = validate_file_size(file_bytes, filename)
# Then validate type
mime_type, extension = validate_file_type(file_bytes, filename)
# Extension cross-check — catches files whose content MIME passes but
# extension was crafted to bypass downstream extension-based filters.
extension_valid = (extension in _ALLOWED_EXTENSIONS) if extension else True
if not extension_valid:
raise FileValidationError(
f"File extension '.{extension}' does not match any extension "
f"allowed for content type '{mime_type}'. This may indicate a "
"renamed or spoofed file."
)
return {
"valid": True,
"mime_type": mime_type,
"extension": extension,
"extension_valid": extension_valid,
"size_bytes": size_bytes,
"size_mb": round(size_bytes / (1024 * 1024), 2),
"filename": filename,
}
|