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,411 @@
1
+ """ZIP content inspector for security threat detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import time
8
+ import zipfile
9
+ from typing import TYPE_CHECKING
10
+
11
+ import logging
12
+ from ..enums import SuspiciousFilePattern, ZipThreatCategory
13
+ from ..exceptions import ZipContentError, FileProcessingError, ErrorCode
14
+
15
+ if TYPE_CHECKING:
16
+ from ..config import FileSecurityConfig
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class ZipContentInspector:
23
+ """
24
+ Inspects ZIP archive contents for security threats.
25
+
26
+ Attributes:
27
+ config: File security configuration.
28
+ """
29
+
30
+ def __init__(self, config: FileSecurityConfig):
31
+ """
32
+ Initialize ZIP inspector with configuration.
33
+
34
+ Args:
35
+ config: File security configuration.
36
+ """
37
+ self.config = config
38
+
39
+ def inspect_zip_content(self, file_content: bytes) -> None:
40
+ """
41
+ Inspect ZIP archive for potential security threats.
42
+
43
+ Args:
44
+ file_content: Raw bytes of ZIP archive.
45
+
46
+ Raises:
47
+ ZipContentError: If security threats are detected in ZIP
48
+ content such as directory traversal, symlinks, nested
49
+ archives, or suspicious patterns.
50
+ FileProcessingError: If ZIP structure is invalid or
51
+ unexpected error occurs during inspection.
52
+ """
53
+ try:
54
+ zip_bytes = io.BytesIO(file_content)
55
+ threats_found = []
56
+
57
+ # Start analysis timer
58
+ start_time = time.time()
59
+
60
+ with zipfile.ZipFile(zip_bytes, "r") as zip_file:
61
+ zip_entries = zip_file.infolist()
62
+
63
+ # Analyze each entry in the ZIP
64
+ for entry in zip_entries:
65
+ # Check for timeout
66
+ if (
67
+ time.time() - start_time
68
+ > self.config.limits.zip_analysis_timeout
69
+ ):
70
+ logger.error(
71
+ "ZIP content inspection timeout",
72
+ extra={
73
+ "error_type": "zip_analysis_timeout",
74
+ "timeout": self.config.limits.zip_analysis_timeout,
75
+ },
76
+ )
77
+ raise ZipContentError(
78
+ message=f"ZIP content inspection timeout after {self.config.limits.zip_analysis_timeout}s",
79
+ threats=["Analysis timeout - potential zip bomb"],
80
+ error_code=ErrorCode.ZIP_ANALYSIS_TIMEOUT,
81
+ )
82
+
83
+ # Inspect individual entry
84
+ entry_threats = self._inspect_zip_entry(entry, zip_file)
85
+ threats_found.extend(entry_threats)
86
+
87
+ # Check for ZIP structure threats
88
+ structure_threats = self._inspect_zip_structure(zip_entries)
89
+ threats_found.extend(structure_threats)
90
+
91
+ # Return results
92
+ if threats_found:
93
+ logger.warning(
94
+ "ZIP content threats detected",
95
+ extra={
96
+ "error_type": "zip_content_threat",
97
+ "threats": threats_found,
98
+ "threat_count": len(threats_found),
99
+ },
100
+ )
101
+ raise ZipContentError(
102
+ message=f"ZIP content threats detected: {'; '.join(threats_found)}",
103
+ threats=threats_found,
104
+ )
105
+
106
+ logger.debug(
107
+ "ZIP content inspection passed: %s entries analyzed",
108
+ len(zip_entries),
109
+ )
110
+
111
+ except ZipContentError:
112
+ # Re-raise our own exceptions
113
+ raise
114
+ except zipfile.BadZipFile as err:
115
+ logger.error("Invalid or corrupted ZIP file structure", exc_info=True)
116
+ raise FileProcessingError(
117
+ message="Invalid or corrupted ZIP file structure",
118
+ original_error=err,
119
+ ) from err
120
+ except Exception as err:
121
+ logger.error(
122
+ "Unexpected error during ZIP content inspection",
123
+ exc_info=True,
124
+ )
125
+ raise FileProcessingError(
126
+ message=f"ZIP content inspection failed: {str(err)}",
127
+ original_error=err,
128
+ ) from err
129
+
130
+ def _inspect_zip_entry(
131
+ self, entry: zipfile.ZipInfo, zip_file: zipfile.ZipFile
132
+ ) -> list[str]:
133
+ """
134
+ Inspect single ZIP entry for security threats.
135
+
136
+ Args:
137
+ entry: ZIP entry metadata.
138
+ zip_file: Parent ZIP archive.
139
+
140
+ Returns:
141
+ List of threat descriptions.
142
+ """
143
+ threats = []
144
+ filename = entry.filename
145
+
146
+ # 1. Check for directory traversal attacks
147
+ if self._has_directory_traversal(filename):
148
+ threats.append(f"Directory traversal attack in '{filename}'")
149
+
150
+ # 2. Check for absolute paths
151
+ if not self.config.limits.allow_absolute_paths and self._has_absolute_path(
152
+ filename
153
+ ):
154
+ threats.append(f"Absolute path detected in '{filename}'")
155
+
156
+ # 3. Check for symbolic links
157
+ if not self.config.limits.allow_symlinks and self._is_symlink(entry):
158
+ threats.append(f"Symbolic link detected: '{filename}'")
159
+
160
+ # 4. Check filename length limits
161
+ if len(os.path.basename(filename)) > self.config.limits.max_filename_length:
162
+ threats.append(
163
+ f"Filename too long: '{filename}' ({len(os.path.basename(filename))} chars)"
164
+ )
165
+
166
+ # 5. Check path length limits
167
+ if len(filename) > self.config.limits.max_path_length:
168
+ threats.append(f"Path too long: '{filename}' ({len(filename)} chars)")
169
+
170
+ # 6. Check for suspicious filename patterns
171
+ suspicious_patterns = self._check_suspicious_patterns(filename)
172
+ threats.extend(suspicious_patterns)
173
+
174
+ # 7. Check for nested archives
175
+ if not self.config.limits.allow_nested_archives and self._is_nested_archive(
176
+ filename
177
+ ):
178
+ threats.append(f"Nested archive detected: '{filename}'")
179
+
180
+ # 8. Check file content if enabled and entry is small enough
181
+ if (
182
+ self.config.limits.scan_zip_content
183
+ and not entry.is_dir()
184
+ and entry.file_size < 1024 * 1024
185
+ ): # 1MB limit for content scan
186
+ content_threats = self._inspect_entry_content(entry, zip_file)
187
+ threats.extend(content_threats)
188
+
189
+ return threats
190
+
191
+ def _inspect_zip_structure(self, entries: list[zipfile.ZipInfo]) -> list[str]:
192
+ """
193
+ Inspect ZIP structure for anomalies.
194
+
195
+ Args:
196
+ entries: All ZIP entries to analyze.
197
+
198
+ Returns:
199
+ List of structural threat descriptions.
200
+ """
201
+ threats = []
202
+
203
+ # Check directory depth
204
+ max_depth = 0
205
+ for entry in entries:
206
+ depth = entry.filename.count("/") + entry.filename.count("\\")
207
+ max_depth = max(max_depth, depth)
208
+
209
+ if max_depth > self.config.limits.max_zip_depth:
210
+ threats.append(
211
+ f"Excessive directory depth: {max_depth} (max: {self.config.limits.max_zip_depth})"
212
+ )
213
+
214
+ # Check for suspicious file distribution
215
+ file_types = {}
216
+ for entry in entries:
217
+ if not entry.is_dir():
218
+ ext = os.path.splitext(entry.filename)[1].lower()
219
+ file_types[ext] = file_types.get(ext, 0) + 1
220
+
221
+ # Check for excessive number of same-type files (potential spam/bomb)
222
+ for ext, count in file_types.items():
223
+ if count > 1000: # More than 1000 files of same type
224
+ threats.append(f"Excessive number of {ext} files: {count}")
225
+
226
+ return threats
227
+
228
+ def _has_directory_traversal(self, filename: str) -> bool:
229
+ """
230
+ Check for directory traversal indicators.
231
+
232
+ Args:
233
+ filename: Filename to check.
234
+
235
+ Returns:
236
+ True if traversal detected.
237
+ """
238
+ filename_lower = filename.lower()
239
+
240
+ for category in SuspiciousFilePattern:
241
+ if category == SuspiciousFilePattern.DIRECTORY_TRAVERSAL:
242
+ for pattern in category.value:
243
+ if pattern.lower() in filename_lower:
244
+ return True
245
+
246
+ # Additional checks for normalized paths
247
+ normalized = os.path.normpath(filename)
248
+ if normalized.startswith("..") or "/.." in normalized or "\\.." in normalized:
249
+ return True
250
+
251
+ return False
252
+
253
+ def _has_absolute_path(self, filename: str) -> bool:
254
+ """
255
+ Check if filename is an absolute path.
256
+
257
+ Args:
258
+ filename: Path to check.
259
+
260
+ Returns:
261
+ True if absolute path detected.
262
+ """
263
+ return (
264
+ filename.startswith("/") # Unix absolute path
265
+ or filename.startswith("\\") # Windows UNC path
266
+ or (len(filename) > 1 and filename[1] == ":") # Windows drive path
267
+ )
268
+
269
+ def _is_symlink(self, entry: zipfile.ZipInfo) -> bool:
270
+ """
271
+ Check if entry is a symbolic link.
272
+
273
+ Args:
274
+ entry: ZIP entry to check.
275
+
276
+ Returns:
277
+ True if entry is a symlink.
278
+ """
279
+ # Check if entry has symlink attributes
280
+ return (entry.external_attr >> 16) & 0o120000 == 0o120000
281
+
282
+ def _check_suspicious_patterns(self, filename: str) -> list[str]:
283
+ """
284
+ Check filename for suspicious patterns.
285
+
286
+ Args:
287
+ filename: Filename to check.
288
+
289
+ Returns:
290
+ List of pattern warnings.
291
+ """
292
+ threats = []
293
+ filename_lower = filename.lower()
294
+ basename = os.path.basename(filename_lower)
295
+
296
+ # Check suspicious names
297
+ for pattern in SuspiciousFilePattern.SUSPICIOUS_NAMES.value:
298
+ if basename == pattern.lower():
299
+ threats.append(f"Suspicious filename pattern: '{filename}'")
300
+ break
301
+
302
+ # Check suspicious path components
303
+ for pattern in SuspiciousFilePattern.SUSPICIOUS_PATHS.value:
304
+ if pattern.lower() in filename_lower:
305
+ threats.append(
306
+ f"Suspicious path component: '{filename}' contains '{pattern}'"
307
+ )
308
+ break
309
+
310
+ return threats
311
+
312
+ def _is_nested_archive(self, filename: str) -> bool:
313
+ """
314
+ Check if filename represents a nested archive.
315
+
316
+ Args:
317
+ filename: Filename to check.
318
+
319
+ Returns:
320
+ True if nested archive detected.
321
+ """
322
+ ext = os.path.splitext(filename)[1].lower()
323
+
324
+ for category in ZipThreatCategory:
325
+ if category == ZipThreatCategory.NESTED_ARCHIVES:
326
+ return ext in category.value
327
+
328
+ return False
329
+
330
+ def _inspect_entry_content(
331
+ self, entry: zipfile.ZipInfo, zip_file: zipfile.ZipFile
332
+ ) -> list[str]:
333
+ """
334
+ Inspect ZIP entry content for malicious signatures.
335
+
336
+ Args:
337
+ entry: ZIP entry to inspect.
338
+ zip_file: Parent ZIP archive.
339
+
340
+ Returns:
341
+ List of content threat descriptions.
342
+ """
343
+ threats = []
344
+
345
+ try:
346
+ # Read first few bytes to check for executable signatures
347
+ with zip_file.open(entry, "r") as file:
348
+ content_sample = file.read(512) # Read first 512 bytes
349
+
350
+ # Check for executable signatures
351
+ for signature in SuspiciousFilePattern.EXECUTABLE_SIGNATURES.value:
352
+ if content_sample.startswith(signature):
353
+ threats.append(
354
+ f"Executable content detected in '{entry.filename}'"
355
+ )
356
+ break
357
+
358
+ # Check for script content patterns
359
+ if self._contains_script_patterns(content_sample, entry.filename):
360
+ threats.append(f"Script content detected in '{entry.filename}'")
361
+
362
+ except Exception as err:
363
+ logger.warning(
364
+ "Could not inspect content of '%s': %s",
365
+ entry.filename,
366
+ err,
367
+ )
368
+
369
+ return threats
370
+
371
+ def _contains_script_patterns(self, content: bytes, filename: str) -> bool:
372
+ """
373
+ Check content for malicious script patterns.
374
+
375
+ Args:
376
+ content: Raw bytes to inspect.
377
+ filename: Filename for context.
378
+
379
+ Returns:
380
+ True if script patterns found.
381
+ """
382
+ try:
383
+ # Try to decode as text
384
+ text_content = content.decode("utf-8", errors="ignore").lower()
385
+
386
+ # Check for common script patterns
387
+ script_patterns = [
388
+ "#!/bin/",
389
+ "#!/usr/bin/",
390
+ "powershell",
391
+ "cmd.exe",
392
+ "eval(",
393
+ "exec(",
394
+ "system(",
395
+ "shell_exec(",
396
+ "<script",
397
+ "<?php",
398
+ "<%",
399
+ "import os",
400
+ "import subprocess",
401
+ ]
402
+
403
+ for pattern in script_patterns:
404
+ if pattern in text_content:
405
+ return True
406
+
407
+ except Exception:
408
+ # If we can't decode as text, it's probably binary
409
+ pass
410
+
411
+ return False
@@ -0,0 +1,51 @@
1
+ """
2
+ Framework-agnostic protocols for file upload handling.
3
+
4
+ This module defines protocols that allow safeuploads to work with any
5
+ web framework's file upload implementation without depending on specific
6
+ framework packages.
7
+ """
8
+
9
+ from typing import Protocol, runtime_checkable
10
+
11
+
12
+ @runtime_checkable
13
+ class UploadFileProtocol(Protocol):
14
+ """
15
+ Protocol for file upload objects from any web framework.
16
+
17
+ This protocol defines the minimal interface required for file
18
+ validation. Any object with these attributes and methods can be
19
+ validated, regardless of the web framework being used.
20
+
21
+ Attributes:
22
+ filename: Original filename from the client.
23
+ size: Size of the uploaded file in bytes.
24
+ """
25
+
26
+ filename: str | None
27
+ size: int | None
28
+
29
+ async def read(self, size: int = -1) -> bytes:
30
+ """
31
+ Read bytes from the uploaded file.
32
+
33
+ Args:
34
+ size: Number of bytes to read. -1 reads entire file.
35
+
36
+ Returns:
37
+ Bytes read from the file.
38
+ """
39
+ ...
40
+
41
+ async def seek(self, offset: int) -> int:
42
+ """
43
+ Move file pointer to specified position.
44
+
45
+ Args:
46
+ offset: Position to move to in bytes.
47
+
48
+ Returns:
49
+ New position in the file.
50
+ """
51
+ ...
@@ -0,0 +1,22 @@
1
+ """
2
+ Security validation modules for uploaded files.
3
+
4
+ This package provides validators that check filenames and file
5
+ properties for potential security threats including Unicode attacks,
6
+ invalid extensions, Windows-specific vulnerabilities, and compression
7
+ bombs.
8
+ """
9
+
10
+ from .base import BaseValidator
11
+ from .unicode_validator import UnicodeSecurityValidator
12
+ from .extension_validator import ExtensionSecurityValidator
13
+ from .windows_validator import WindowsSecurityValidator
14
+ from .compression_validator import CompressionSecurityValidator
15
+
16
+ __all__ = [
17
+ "BaseValidator",
18
+ "UnicodeSecurityValidator",
19
+ "ExtensionSecurityValidator",
20
+ "WindowsSecurityValidator",
21
+ "CompressionSecurityValidator",
22
+ ]
@@ -0,0 +1,43 @@
1
+ """
2
+ Base validator interface for file security checks.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from ..config import FileSecurityConfig
12
+
13
+
14
+ class BaseValidator(ABC):
15
+ """
16
+ Abstract base class for file security validators.
17
+
18
+ Attributes:
19
+ config: File security configuration parameters.
20
+ """
21
+
22
+ def __init__(self, config: FileSecurityConfig):
23
+ """
24
+ Initialize validator with configuration.
25
+
26
+ Args:
27
+ config: File security settings to apply.
28
+ """
29
+ self.config = config
30
+
31
+ @abstractmethod
32
+ def validate(self, *args, **kwargs) -> Any:
33
+ """
34
+ Validate data using subclass-specific logic.
35
+
36
+ Args:
37
+ *args: Positional arguments for concrete validator.
38
+ **kwargs: Keyword arguments for concrete validator.
39
+
40
+ Returns:
41
+ Validated result defined by subclass.
42
+ """
43
+ pass