import os from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ # Maps file extension to (mime_type, list_of_(offset, magic_bytes) tuples) _SIGNATURE_MAP: dict[str, list[tuple[int, bytes]]] = { ".pdf": [(0, b"%PDF")], ".jpg": [(0, b"\xFF\xD8\xFF")], ".jpeg": [(0, b"\xFF\xD8\xFF")], ".png": [(0, b"\x89PNG")], ".gif": [(0, b"GIF87a"), (0, b"GIF89a")], # ZIP-based Office formats (DOCX, XLSX, PPTX) ".docx": [(0, b"PK\x03\x04")], ".xlsx": [(0, b"PK\x03\x04")], ".pptx": [(0, b"PK\x03\x04")], # Legacy Office (compound document) ".doc": [(0, b"\xD0\xCF\x11\xE0")], ".xls": [(0, b"\xD0\xCF\x11\xE0")], ".ppt": [(0, b"\xD0\xCF\x11\xE0")], # MP4 (ftyp box at offset 4) ".mp4": [(4, b"ftyp")], ".webm": [(0, b"\x1aE\xdf\xa3")], } # Extensions without signature checks (plain text formats) _NO_SIGNATURE_EXTS = {".csv", ".txt", ".avi", ".mov"} def _allowed_extensions() -> set[str]: return {ext.lower() for ext in getattr(settings, "ALLOWED_UPLOAD_EXTENSIONS", [])} def validate_file_extension(file) -> None: ext = os.path.splitext(file.name)[1].lower() if ext not in _allowed_extensions(): raise ValidationError( _("File type '%(ext)s' is not permitted."), params={"ext": ext}, code="invalid_extension", ) def validate_file_size(file) -> None: max_bytes = getattr(settings, "MAX_UPLOAD_SIZE_BYTES", 100 * 1024 * 1024) if file.size > max_bytes: raise ValidationError( _("File size %(size_mb).1f MB exceeds the %(max_mb)d MB limit."), params={ "size_mb": file.size / 1024 / 1024, "max_mb": max_bytes // 1024 // 1024, }, code="file_too_large", ) def validate_file_signature(file) -> None: """Reject files whose magic bytes contradict the declared extension.""" ext = os.path.splitext(file.name)[1].lower() if ext in _NO_SIGNATURE_EXTS: return signatures = _SIGNATURE_MAP.get(ext) if not signatures: return file.seek(0) header = file.read(16) file.seek(0) for offset, magic in signatures: if header[offset : offset + len(magic)] == magic: return raise ValidationError( _("File content does not match the declared file type '%(ext)s'."), params={"ext": ext}, code="invalid_file_signature", ) def validate_upload(file) -> None: """Run all upload validators in sequence (extension → size → signature).""" validate_file_extension(file) validate_file_size(file) validate_file_signature(file)