safeuploads 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- safeuploads/__init__.py +92 -0
- safeuploads/config.py +842 -0
- safeuploads/enums.py +455 -0
- safeuploads/exceptions.py +468 -0
- safeuploads/file_validator.py +525 -0
- safeuploads/inspectors/__init__.py +10 -0
- safeuploads/inspectors/zip_inspector.py +411 -0
- safeuploads/protocols.py +51 -0
- safeuploads/validators/__init__.py +22 -0
- safeuploads/validators/base.py +43 -0
- safeuploads/validators/compression_validator.py +286 -0
- safeuploads/validators/extension_validator.py +97 -0
- safeuploads/validators/unicode_validator.py +132 -0
- safeuploads/validators/windows_validator.py +95 -0
- safeuploads-0.1.0.dist-info/METADATA +170 -0
- safeuploads-0.1.0.dist-info/RECORD +18 -0
- safeuploads-0.1.0.dist-info/WHEEL +4 -0
- safeuploads-0.1.0.dist-info/licenses/LICENSE.md +7 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Main file validator coordinating all security validations."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import mimetypes
|
|
7
|
+
|
|
8
|
+
import magic
|
|
9
|
+
|
|
10
|
+
# Optional FastAPI integration - fallback to protocol if not available
|
|
11
|
+
try:
|
|
12
|
+
from fastapi import UploadFile
|
|
13
|
+
except ImportError:
|
|
14
|
+
from .protocols import UploadFileProtocol as UploadFile
|
|
15
|
+
|
|
16
|
+
from .config import FileSecurityConfig
|
|
17
|
+
from .validators import (
|
|
18
|
+
UnicodeSecurityValidator,
|
|
19
|
+
ExtensionSecurityValidator,
|
|
20
|
+
WindowsSecurityValidator,
|
|
21
|
+
CompressionSecurityValidator,
|
|
22
|
+
)
|
|
23
|
+
from .inspectors import ZipContentInspector
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
ErrorCode,
|
|
26
|
+
FileValidationError,
|
|
27
|
+
FilenameSecurityError,
|
|
28
|
+
ExtensionSecurityError,
|
|
29
|
+
FileSizeError,
|
|
30
|
+
MimeTypeError,
|
|
31
|
+
FileSignatureError,
|
|
32
|
+
FileProcessingError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FileValidator:
|
|
40
|
+
"""
|
|
41
|
+
Coordinated security validation for uploaded files.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
config: Active security configuration.
|
|
45
|
+
unicode_validator: Validator for Unicode-related checks.
|
|
46
|
+
extension_validator: Validator for file extension rules.
|
|
47
|
+
windows_validator: Validator enforcing Windows-specific constraints.
|
|
48
|
+
compression_validator: Validator handling compressed file limits.
|
|
49
|
+
zip_inspector: Inspector for ZIP archive contents.
|
|
50
|
+
magic_mime: MIME type detector based on python-magic.
|
|
51
|
+
magic_available: Whether python-magic was successfully initialized.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, config: FileSecurityConfig | None = None):
|
|
55
|
+
"""
|
|
56
|
+
Initialize file validator with configuration and detection utilities.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
config: Optional configuration object defining file security
|
|
60
|
+
rules. Defaults to new FileSecurityConfig instance.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
config: Active security configuration.
|
|
64
|
+
unicode_validator: Validator for Unicode-related checks.
|
|
65
|
+
extension_validator: Validator for file extension rules.
|
|
66
|
+
windows_validator: Validator enforcing Windows constraints.
|
|
67
|
+
compression_validator: Validator for compressed file limits.
|
|
68
|
+
zip_inspector: Inspector for ZIP archive contents.
|
|
69
|
+
magic_mime: MIME type detector based on python-magic.
|
|
70
|
+
magic_available: Whether python-magic initialized successfully.
|
|
71
|
+
"""
|
|
72
|
+
self.config = config or FileSecurityConfig()
|
|
73
|
+
|
|
74
|
+
# Initialize specialized validators
|
|
75
|
+
self.unicode_validator = UnicodeSecurityValidator(self.config)
|
|
76
|
+
self.extension_validator = ExtensionSecurityValidator(self.config)
|
|
77
|
+
self.windows_validator = WindowsSecurityValidator(self.config)
|
|
78
|
+
self.compression_validator = CompressionSecurityValidator(self.config)
|
|
79
|
+
self.zip_inspector = ZipContentInspector(self.config)
|
|
80
|
+
|
|
81
|
+
# Initialize python-magic for content-based detection
|
|
82
|
+
try:
|
|
83
|
+
self.magic_mime = magic.Magic(mime=True)
|
|
84
|
+
self.magic_available = True
|
|
85
|
+
logger.debug("File content detection (python-magic) initialized")
|
|
86
|
+
except Exception as err:
|
|
87
|
+
self.magic_available = False
|
|
88
|
+
logger.warning(
|
|
89
|
+
"python-magic not available for content detection: %s",
|
|
90
|
+
err,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _detect_mime_type(self, file_content: bytes, filename: str) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Determine MIME type for file content.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
file_content: Raw bytes of the file to inspect.
|
|
99
|
+
filename: Original filename for fallback MIME detection.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Detected MIME type or "application/octet-stream" if detection
|
|
103
|
+
fails.
|
|
104
|
+
"""
|
|
105
|
+
detected_mime = None
|
|
106
|
+
|
|
107
|
+
# Content-based detection using python-magic (most reliable)
|
|
108
|
+
if self.magic_available:
|
|
109
|
+
try:
|
|
110
|
+
detected_mime = self.magic_mime.from_buffer(file_content)
|
|
111
|
+
except Exception as err:
|
|
112
|
+
logger.warning("Magic MIME detection failed: %s", err)
|
|
113
|
+
|
|
114
|
+
# Fallback to filename-based detection
|
|
115
|
+
if not detected_mime:
|
|
116
|
+
logger.info("Fallback to filename-based MIME detection")
|
|
117
|
+
detected_mime, _ = mimetypes.guess_type(filename)
|
|
118
|
+
|
|
119
|
+
return detected_mime or "application/octet-stream"
|
|
120
|
+
|
|
121
|
+
def _validate_file_signature(self, file_content: bytes, expected_type: str) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Verify file content begins with known signature for expected type.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
file_content: Raw bytes of the uploaded file.
|
|
127
|
+
expected_type: Logical file category ("image" or "zip").
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
FileSignatureError: File header doesn't match expected type
|
|
131
|
+
signatures.
|
|
132
|
+
"""
|
|
133
|
+
if len(file_content) < 4:
|
|
134
|
+
raise FileSignatureError(
|
|
135
|
+
f"File too small to verify {expected_type} signature",
|
|
136
|
+
expected_type=expected_type,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Common file signatures
|
|
140
|
+
signatures = {
|
|
141
|
+
"image": [
|
|
142
|
+
b"\xff\xd8\xff", # JPEG
|
|
143
|
+
b"\xff\xd8\xff\xe1", # JPEG EXIF (additional JPEG variant)
|
|
144
|
+
b"\x89PNG\r\n\x1a\n", # PNG
|
|
145
|
+
],
|
|
146
|
+
"zip": [
|
|
147
|
+
b"PK\x03\x04", # ZIP file
|
|
148
|
+
b"PK\x05\x06", # Empty ZIP
|
|
149
|
+
b"PK\x07\x08", # ZIP with spanning
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
expected_signatures = signatures.get(expected_type, [])
|
|
154
|
+
|
|
155
|
+
for signature in expected_signatures:
|
|
156
|
+
if file_content.startswith(signature):
|
|
157
|
+
return # Signature matched
|
|
158
|
+
|
|
159
|
+
# No matching signature found
|
|
160
|
+
raise FileSignatureError(
|
|
161
|
+
f"File content does not match expected {expected_type} format",
|
|
162
|
+
expected_type=expected_type,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _sanitize_filename(self, filename: str) -> str:
|
|
166
|
+
"""
|
|
167
|
+
Sanitize user-provided filename to prevent security risks.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
filename: Original filename supplied by the user.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Sanitized filename safe for storage and processing.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
UnicodeSecurityError: Filename contains dangerous Unicode
|
|
177
|
+
characters or fails normalization checks.
|
|
178
|
+
WindowsReservedNameError: Filename uses Windows reserved
|
|
179
|
+
device names.
|
|
180
|
+
ExtensionSecurityError: Filename contains blocked or
|
|
181
|
+
dangerous file extensions.
|
|
182
|
+
ValueError: Filename is empty string.
|
|
183
|
+
"""
|
|
184
|
+
if not filename:
|
|
185
|
+
raise ValueError("Filename cannot be empty")
|
|
186
|
+
|
|
187
|
+
# Unicode security validation (must be first)
|
|
188
|
+
# This detects and blocks Unicode-based attacks before any other processing
|
|
189
|
+
filename = self.unicode_validator.validate_unicode_security(filename)
|
|
190
|
+
|
|
191
|
+
# Remove path components to prevent directory traversal
|
|
192
|
+
filename = os.path.basename(filename)
|
|
193
|
+
|
|
194
|
+
# Remove null bytes and control characters
|
|
195
|
+
filename = "".join(
|
|
196
|
+
char for char in filename if ord(char) >= 32 and char != "\x7f"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Remove dangerous characters that could be used for path traversal or command injection
|
|
200
|
+
dangerous_chars = '<>:"/\\|?*\x00'
|
|
201
|
+
for char in dangerous_chars:
|
|
202
|
+
filename = filename.replace(char, "_")
|
|
203
|
+
|
|
204
|
+
# Check for Windows reserved names before any other processing
|
|
205
|
+
# This must be done early to prevent reserved names from being created
|
|
206
|
+
self.windows_validator.validate_windows_reserved_names(filename)
|
|
207
|
+
|
|
208
|
+
# Handle compound and double extensions security risk
|
|
209
|
+
# This also checks all dangerous extensions
|
|
210
|
+
self.extension_validator.validate_extensions(filename)
|
|
211
|
+
|
|
212
|
+
# Limit filename length (preserve extension)
|
|
213
|
+
name_part, ext_part = os.path.splitext(filename)
|
|
214
|
+
if len(name_part) > 100:
|
|
215
|
+
name_part = name_part[:100]
|
|
216
|
+
filename = name_part + ext_part
|
|
217
|
+
|
|
218
|
+
# Ensure we don't end up with just an extension or empty name
|
|
219
|
+
if not name_part or name_part.strip() == "":
|
|
220
|
+
filename = f"file_{int(time.time())}{ext_part}"
|
|
221
|
+
|
|
222
|
+
# Final check: ensure the sanitized filename doesn't become a reserved name
|
|
223
|
+
self.windows_validator.validate_windows_reserved_names(filename)
|
|
224
|
+
|
|
225
|
+
logger.debug(
|
|
226
|
+
"Filename sanitized: original='%s' -> sanitized='%s'",
|
|
227
|
+
os.path.basename(filename if filename else "None"),
|
|
228
|
+
filename,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return filename
|
|
232
|
+
|
|
233
|
+
def _validate_filename(self, file: UploadFile) -> None:
|
|
234
|
+
"""
|
|
235
|
+
Validate filename of uploaded file and sanitize it in place.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
file: Uploaded file whose filename should be validated and
|
|
239
|
+
sanitized.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
FilenameSecurityError: Filename is empty, invalid, or fails
|
|
243
|
+
sanitization.
|
|
244
|
+
FileProcessingError: Unexpected error during filename
|
|
245
|
+
validation.
|
|
246
|
+
"""
|
|
247
|
+
# Check filename
|
|
248
|
+
if not file.filename:
|
|
249
|
+
raise FilenameSecurityError(
|
|
250
|
+
"Filename is required",
|
|
251
|
+
error_code=ErrorCode.FILENAME_EMPTY,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Sanitize the filename to prevent security issues
|
|
255
|
+
try:
|
|
256
|
+
sanitized_filename = self._sanitize_filename(file.filename)
|
|
257
|
+
|
|
258
|
+
# Update the file object with sanitized filename
|
|
259
|
+
file.filename = sanitized_filename
|
|
260
|
+
|
|
261
|
+
# Additional validation after sanitization
|
|
262
|
+
if not sanitized_filename or sanitized_filename.strip() == "":
|
|
263
|
+
raise FilenameSecurityError(
|
|
264
|
+
"Invalid filename after sanitization",
|
|
265
|
+
filename=file.filename,
|
|
266
|
+
error_code=ErrorCode.FILENAME_INVALID,
|
|
267
|
+
)
|
|
268
|
+
except FileValidationError:
|
|
269
|
+
# Let FileValidationError and subclasses propagate
|
|
270
|
+
raise
|
|
271
|
+
except Exception as err:
|
|
272
|
+
logger.exception("Unexpected error during filename validation: %s", err)
|
|
273
|
+
raise FileProcessingError(
|
|
274
|
+
"Filename validation failed due to internal error",
|
|
275
|
+
original_error=err,
|
|
276
|
+
) from err
|
|
277
|
+
|
|
278
|
+
def _validate_file_extension(
|
|
279
|
+
self, file: UploadFile, allowed_extensions: set[str]
|
|
280
|
+
) -> None:
|
|
281
|
+
"""
|
|
282
|
+
Validate extension of uploaded file against allowed and blocked lists.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
file: File whose extension will be validated.
|
|
286
|
+
allowed_extensions: Set of allowed file extensions.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
FilenameSecurityError: Filename is missing.
|
|
290
|
+
ExtensionSecurityError: Extension is not allowed or is blocked.
|
|
291
|
+
"""
|
|
292
|
+
# Check file extension
|
|
293
|
+
if not file.filename:
|
|
294
|
+
raise FilenameSecurityError(
|
|
295
|
+
"Filename is required for extension validation",
|
|
296
|
+
error_code=ErrorCode.FILENAME_EMPTY,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
_, ext = os.path.splitext(file.filename.lower())
|
|
300
|
+
if ext not in allowed_extensions:
|
|
301
|
+
raise ExtensionSecurityError(
|
|
302
|
+
f"Invalid file extension. Allowed: {', '.join(allowed_extensions)}",
|
|
303
|
+
filename=file.filename,
|
|
304
|
+
extension=ext,
|
|
305
|
+
error_code=ErrorCode.EXTENSION_NOT_ALLOWED,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Check for blocked extensions
|
|
309
|
+
if ext in self.config.BLOCKED_EXTENSIONS:
|
|
310
|
+
raise ExtensionSecurityError(
|
|
311
|
+
f"File extension {ext} is blocked for security reasons",
|
|
312
|
+
filename=file.filename,
|
|
313
|
+
extension=ext,
|
|
314
|
+
error_code=ErrorCode.EXTENSION_BLOCKED,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def _validate_file_size(
|
|
318
|
+
self, file: UploadFile, max_file_size: int
|
|
319
|
+
) -> tuple[bytes, int]:
|
|
320
|
+
"""
|
|
321
|
+
Validate uploaded file size by sampling content and determining total bytes.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
file: Uploaded file supporting asynchronous read and seek.
|
|
325
|
+
max_file_size: Maximum allowed file size in bytes.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Tuple containing first 8 KB of file content and detected file
|
|
329
|
+
size in bytes.
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
FileSizeError: File size exceeds maximum or file is empty.
|
|
333
|
+
"""
|
|
334
|
+
# Read first chunk for content analysis
|
|
335
|
+
file_content = await file.read(8192) # Read first 8KB
|
|
336
|
+
|
|
337
|
+
# Reset file position
|
|
338
|
+
await file.seek(0)
|
|
339
|
+
|
|
340
|
+
# Check file size
|
|
341
|
+
file_size = len(file_content)
|
|
342
|
+
if hasattr(file, "size") and file.size:
|
|
343
|
+
file_size = file.size
|
|
344
|
+
else:
|
|
345
|
+
# Estimate size by reading the rest
|
|
346
|
+
remaining = await file.read()
|
|
347
|
+
file_size = len(file_content) + len(remaining)
|
|
348
|
+
await file.seek(0)
|
|
349
|
+
|
|
350
|
+
if file_size > max_file_size:
|
|
351
|
+
raise FileSizeError(
|
|
352
|
+
f"File too large. File size: {file_size // (1024*1024)}MB, maximum: {max_file_size // (1024*1024)}MB",
|
|
353
|
+
size=file_size,
|
|
354
|
+
max_size=max_file_size,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if file_size == 0:
|
|
358
|
+
raise FileSizeError(
|
|
359
|
+
"Empty file not allowed",
|
|
360
|
+
size=0,
|
|
361
|
+
max_size=max_file_size,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return file_content, file_size
|
|
365
|
+
|
|
366
|
+
async def validate_image_file(self, file: UploadFile) -> None:
|
|
367
|
+
"""
|
|
368
|
+
Validate uploaded image by checking filename, extension, size, MIME type, and signature.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
file: Uploaded file to validate.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
FilenameSecurityError: Filename is empty, invalid, or fails
|
|
375
|
+
security checks.
|
|
376
|
+
ExtensionSecurityError: File extension is not allowed or is
|
|
377
|
+
blocked.
|
|
378
|
+
FileSizeError: File size exceeds maximum or file is empty.
|
|
379
|
+
MimeTypeError: MIME type is not in allowed image types.
|
|
380
|
+
FileSignatureError: File signature doesn't match expected image
|
|
381
|
+
format.
|
|
382
|
+
FileProcessingError: Unexpected error during validation.
|
|
383
|
+
"""
|
|
384
|
+
try:
|
|
385
|
+
# Validate filename (raises exceptions on failure)
|
|
386
|
+
self._validate_filename(file)
|
|
387
|
+
|
|
388
|
+
# Validate file extension (raises exceptions on failure)
|
|
389
|
+
self._validate_file_extension(file, self.config.ALLOWED_IMAGE_EXTENSIONS)
|
|
390
|
+
|
|
391
|
+
# Validate file size (raises exceptions on failure, returns content and size on success)
|
|
392
|
+
file_content, file_size = await self._validate_file_size(
|
|
393
|
+
file, self.config.limits.max_image_size
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Detect MIME type
|
|
397
|
+
filename = file.filename or "unknown"
|
|
398
|
+
detected_mime = self._detect_mime_type(file_content, filename)
|
|
399
|
+
|
|
400
|
+
if detected_mime not in self.config.ALLOWED_IMAGE_MIMES:
|
|
401
|
+
raise MimeTypeError(
|
|
402
|
+
f"Invalid file type. Detected: {detected_mime}. Allowed: {', '.join(self.config.ALLOWED_IMAGE_MIMES)}",
|
|
403
|
+
filename=filename,
|
|
404
|
+
detected_mime=detected_mime,
|
|
405
|
+
allowed_mimes=list(self.config.ALLOWED_IMAGE_MIMES),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Validate file signature (raises exceptions on failure)
|
|
409
|
+
self._validate_file_signature(file_content, "image")
|
|
410
|
+
|
|
411
|
+
logger.debug(
|
|
412
|
+
"Image file validation passed: %s (%s, %s bytes)",
|
|
413
|
+
filename,
|
|
414
|
+
detected_mime,
|
|
415
|
+
file_size,
|
|
416
|
+
)
|
|
417
|
+
except FileValidationError:
|
|
418
|
+
# Let FileValidationError and subclasses propagate
|
|
419
|
+
raise
|
|
420
|
+
except Exception as err:
|
|
421
|
+
logger.exception("Error during image file validation: %s", err)
|
|
422
|
+
raise FileProcessingError(
|
|
423
|
+
"File validation failed due to internal error",
|
|
424
|
+
original_error=err,
|
|
425
|
+
) from err
|
|
426
|
+
|
|
427
|
+
async def validate_zip_file(self, file: UploadFile) -> None:
|
|
428
|
+
"""
|
|
429
|
+
Validate uploaded ZIP archive against service configuration.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
file: Incoming ZIP file-like object to validate.
|
|
433
|
+
|
|
434
|
+
Raises:
|
|
435
|
+
FilenameSecurityError: Filename is empty, invalid, or fails
|
|
436
|
+
security checks.
|
|
437
|
+
ExtensionSecurityError: File extension is not allowed or is
|
|
438
|
+
blocked.
|
|
439
|
+
FileSizeError: File size exceeds maximum or file is empty.
|
|
440
|
+
MimeTypeError: MIME type is not in allowed ZIP types.
|
|
441
|
+
FileSignatureError: File signature doesn't match expected ZIP
|
|
442
|
+
format.
|
|
443
|
+
CompressionSecurityError: ZIP compression validation failed
|
|
444
|
+
(zip bomb detected).
|
|
445
|
+
FileProcessingError: Unexpected error during validation.
|
|
446
|
+
"""
|
|
447
|
+
try:
|
|
448
|
+
# Validate filename (raises exceptions on failure)
|
|
449
|
+
self._validate_filename(file)
|
|
450
|
+
|
|
451
|
+
# Validate file extension (raises exceptions on failure)
|
|
452
|
+
self._validate_file_extension(file, self.config.ALLOWED_ZIP_EXTENSIONS)
|
|
453
|
+
|
|
454
|
+
# Validate file size (raises exceptions on failure, returns content and size on success)
|
|
455
|
+
file_content, file_size = await self._validate_file_size(
|
|
456
|
+
file, self.config.limits.max_zip_size
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Detect MIME type using first 8KB
|
|
460
|
+
filename = file.filename or "unknown"
|
|
461
|
+
detected_mime = self._detect_mime_type(file_content, filename)
|
|
462
|
+
|
|
463
|
+
# Validate ZIP file signature first (most reliable check)
|
|
464
|
+
# This will raise FileSignatureError if signature doesn't match
|
|
465
|
+
try:
|
|
466
|
+
self._validate_file_signature(file_content, "zip")
|
|
467
|
+
except FileSignatureError as err:
|
|
468
|
+
# Re-raise with more specific message
|
|
469
|
+
raise FileSignatureError(
|
|
470
|
+
"File content does not match ZIP format",
|
|
471
|
+
filename=filename,
|
|
472
|
+
expected_type="zip",
|
|
473
|
+
) from err
|
|
474
|
+
|
|
475
|
+
# Check MIME type, but allow application/octet-stream if signature is valid
|
|
476
|
+
# Some ZIP files are detected as octet-stream, but signature check ensures it's really a ZIP
|
|
477
|
+
if detected_mime not in self.config.ALLOWED_ZIP_MIMES:
|
|
478
|
+
if detected_mime == "application/octet-stream":
|
|
479
|
+
# Valid ZIP file, just detected as generic binary
|
|
480
|
+
logger.debug(
|
|
481
|
+
"ZIP file detected as application/octet-stream, but signature is valid: %s",
|
|
482
|
+
filename,
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
raise MimeTypeError(
|
|
486
|
+
f"Invalid file type. Detected: {detected_mime}. Expected ZIP file.",
|
|
487
|
+
filename=filename,
|
|
488
|
+
detected_mime=detected_mime,
|
|
489
|
+
allowed_mimes=list(self.config.ALLOWED_ZIP_MIMES),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# For ZIP validation (compression ratio and content inspection), we need the full file
|
|
493
|
+
# Read the entire file content for proper ZIP analysis
|
|
494
|
+
await file.seek(0)
|
|
495
|
+
full_file_content = await file.read()
|
|
496
|
+
file_size = len(full_file_content)
|
|
497
|
+
|
|
498
|
+
# Reset file position for any subsequent operations
|
|
499
|
+
await file.seek(0)
|
|
500
|
+
|
|
501
|
+
# Validate ZIP compression ratio to detect zip bombs
|
|
502
|
+
if file_size is not None:
|
|
503
|
+
self.compression_validator.validate_zip_compression_ratio(
|
|
504
|
+
full_file_content, file_size
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Perform ZIP content inspection if enabled
|
|
508
|
+
if self.config.limits.scan_zip_content:
|
|
509
|
+
self.zip_inspector.inspect_zip_content(full_file_content)
|
|
510
|
+
|
|
511
|
+
logger.debug(
|
|
512
|
+
"ZIP file validation passed: %s (%s, %s bytes)",
|
|
513
|
+
filename,
|
|
514
|
+
detected_mime,
|
|
515
|
+
file_size,
|
|
516
|
+
)
|
|
517
|
+
except FileValidationError:
|
|
518
|
+
# Let FileValidationError and subclasses propagate
|
|
519
|
+
raise
|
|
520
|
+
except Exception as err:
|
|
521
|
+
logger.exception("Error during ZIP file validation: %s", err)
|
|
522
|
+
raise FileProcessingError(
|
|
523
|
+
"File validation failed due to internal error",
|
|
524
|
+
original_error=err,
|
|
525
|
+
) from err
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File content inspection modules for security validation.
|
|
3
|
+
|
|
4
|
+
This package provides inspectors that analyze the internal structure
|
|
5
|
+
and contents of uploaded files to detect potential security threats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .zip_inspector import ZipContentInspector
|
|
9
|
+
|
|
10
|
+
__all__ = ["ZipContentInspector"]
|