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.
@@ -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"]