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,286 @@
1
+ """
2
+ Validates ZIP compression ratios and detects zip bombs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import io
8
+ import time
9
+ import zipfile
10
+ import logging
11
+
12
+ from typing import TYPE_CHECKING
13
+ from .base import BaseValidator
14
+ from ..exceptions import (
15
+ ZipBombError,
16
+ CompressionSecurityError,
17
+ FileProcessingError,
18
+ ErrorCode,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from ..config import FileSecurityConfig
23
+
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class CompressionSecurityValidator(BaseValidator):
29
+ """
30
+ Validates ZIP uploads against zip bombs and compression attacks.
31
+
32
+ Attributes:
33
+ config: Security configuration for validation limits.
34
+ """
35
+
36
+ def __init__(self, config: FileSecurityConfig):
37
+ """
38
+ Initialize the compression validator.
39
+
40
+ Args:
41
+ config: Security configuration with compression limits.
42
+ """
43
+ super().__init__(config)
44
+
45
+ def validate_zip_compression_ratio(
46
+ self, file_content: bytes, compressed_size: int
47
+ ) -> None:
48
+ """
49
+ Validate ZIP archive against security limits.
50
+
51
+ Args:
52
+ file_content: Raw bytes of the ZIP archive.
53
+ compressed_size: Size of the compressed archive in bytes.
54
+
55
+ Raises:
56
+ ZipBombError: If compression ratio exceeds maximum allowed
57
+ or total uncompressed size is too large.
58
+ CompressionSecurityError: If ZIP structure is invalid, too
59
+ many entries, nested archives detected, or individual
60
+ file too large.
61
+ FileProcessingError: If unexpected error occurs during
62
+ validation such as memory errors or I/O errors.
63
+ """
64
+ try:
65
+ # Create a BytesIO object from file content for zipfile analysis
66
+ zip_bytes = io.BytesIO(file_content)
67
+
68
+ # Track analysis metrics
69
+ total_uncompressed_size = 0
70
+ total_compressed_size = compressed_size
71
+ file_count = 0
72
+ nested_archives = []
73
+ max_compression_ratio = 0
74
+ overall_compression_ratio = 0 # Initialize to avoid unbound variable
75
+
76
+ # Analyze ZIP file structure with timeout protection
77
+ start_time = time.time()
78
+
79
+ with zipfile.ZipFile(zip_bytes, "r") as zip_file:
80
+ # Check for excessive number of files
81
+ zip_entries = zip_file.infolist()
82
+ file_count = len(zip_entries)
83
+
84
+ if file_count > self.config.limits.max_zip_entries:
85
+ logger.warning(
86
+ "ZIP contains too many files",
87
+ extra={
88
+ "error_type": "zip_too_many_entries",
89
+ "file_count": file_count,
90
+ "max_entries": self.config.limits.max_zip_entries,
91
+ },
92
+ )
93
+ raise CompressionSecurityError(
94
+ message=f"ZIP contains too many files: {file_count}. "
95
+ f"Maximum allowed: {self.config.limits.max_zip_entries}",
96
+ error_code=ErrorCode.ZIP_TOO_MANY_ENTRIES,
97
+ )
98
+
99
+ # Analyze each entry in the ZIP
100
+ for entry in zip_entries:
101
+ # Check for timeout
102
+ if (
103
+ time.time() - start_time
104
+ > self.config.limits.zip_analysis_timeout
105
+ ):
106
+ logger.error(
107
+ "ZIP analysis timeout",
108
+ extra={
109
+ "error_type": "zip_analysis_timeout",
110
+ "timeout": self.config.limits.zip_analysis_timeout,
111
+ },
112
+ )
113
+ raise ZipBombError(
114
+ message=f"ZIP analysis timeout after {self.config.limits.zip_analysis_timeout}s - potential zip bomb",
115
+ compression_ratio=0,
116
+ )
117
+
118
+ # Skip directories
119
+ if entry.is_dir():
120
+ continue
121
+
122
+ # Track uncompressed size
123
+ uncompressed_size = entry.file_size
124
+ compressed_size_entry = entry.compress_size
125
+ total_uncompressed_size += uncompressed_size
126
+
127
+ # Check individual file compression ratio
128
+ if compressed_size_entry > 0: # Avoid division by zero
129
+ compression_ratio = uncompressed_size / compressed_size_entry
130
+ max_compression_ratio = max(
131
+ max_compression_ratio, compression_ratio
132
+ )
133
+
134
+ if compression_ratio > self.config.limits.max_compression_ratio:
135
+ logger.error(
136
+ "Excessive compression ratio detected",
137
+ extra={
138
+ "error_type": "compression_ratio_exceeded",
139
+ "file_name": entry.filename,
140
+ "compression_ratio": compression_ratio,
141
+ "max_ratio": self.config.limits.max_compression_ratio,
142
+ },
143
+ )
144
+ raise ZipBombError(
145
+ message=f"Excessive compression ratio detected: {compression_ratio:.1f}:1 for '{entry.filename}'. "
146
+ f"Maximum allowed: {self.config.limits.max_compression_ratio}:1",
147
+ compression_ratio=compression_ratio,
148
+ )
149
+
150
+ # Check for nested archive files
151
+ filename_lower = entry.filename.lower()
152
+ if any(
153
+ filename_lower.endswith(ext)
154
+ for ext in [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"]
155
+ ):
156
+ nested_archives.append(entry.filename)
157
+
158
+ # Check for excessively large individual files
159
+ # Use the configurable max_individual_file_size limit
160
+ if uncompressed_size > self.config.limits.max_individual_file_size:
161
+ logger.warning(
162
+ "Individual file too large",
163
+ extra={
164
+ "error_type": "file_too_large",
165
+ "file_name": entry.filename,
166
+ "size_mb": uncompressed_size // (1024 * 1024),
167
+ "max_size_mb": self.config.limits.max_individual_file_size
168
+ // (1024 * 1024),
169
+ },
170
+ )
171
+ raise CompressionSecurityError(
172
+ message=f"Individual file too large: '{entry.filename}' would expand to {uncompressed_size // (1024*1024)}MB. "
173
+ f"Maximum allowed: {self.config.limits.max_individual_file_size // (1024*1024)}MB",
174
+ error_code=ErrorCode.FILE_TOO_LARGE,
175
+ )
176
+
177
+ # Check total uncompressed size
178
+ if total_uncompressed_size > self.config.limits.max_uncompressed_size:
179
+ logger.warning(
180
+ "Total uncompressed size too large",
181
+ extra={
182
+ "error_type": "zip_too_large",
183
+ "total_size_mb": total_uncompressed_size // (1024 * 1024),
184
+ "max_size_mb": self.config.limits.max_uncompressed_size
185
+ // (1024 * 1024),
186
+ },
187
+ )
188
+ raise ZipBombError(
189
+ message=f"Total uncompressed size too large: {total_uncompressed_size // (1024*1024)}MB. "
190
+ f"Maximum allowed: {self.config.limits.max_uncompressed_size // (1024*1024)}MB",
191
+ compression_ratio=0,
192
+ uncompressed_size=total_uncompressed_size,
193
+ max_size=self.config.limits.max_uncompressed_size,
194
+ )
195
+
196
+ # Check overall compression ratio
197
+ if total_compressed_size > 0:
198
+ overall_compression_ratio = (
199
+ total_uncompressed_size / total_compressed_size
200
+ )
201
+ if (
202
+ overall_compression_ratio
203
+ > self.config.limits.max_compression_ratio
204
+ ):
205
+ logger.error(
206
+ "Overall compression ratio too high",
207
+ extra={
208
+ "error_type": "compression_ratio_exceeded",
209
+ "overall_ratio": overall_compression_ratio,
210
+ "max_ratio": self.config.limits.max_compression_ratio,
211
+ },
212
+ )
213
+ raise ZipBombError(
214
+ message=f"Overall compression ratio too high: {overall_compression_ratio:.1f}:1. "
215
+ f"Maximum allowed: {self.config.limits.max_compression_ratio}:1",
216
+ compression_ratio=overall_compression_ratio,
217
+ max_ratio=self.config.limits.max_compression_ratio,
218
+ )
219
+
220
+ # Reject nested archives (potential security risk)
221
+ if nested_archives:
222
+ logger.warning(
223
+ "Nested archives detected",
224
+ extra={
225
+ "error_type": "zip_nested_archive",
226
+ "nested_archives": nested_archives,
227
+ },
228
+ )
229
+ raise CompressionSecurityError(
230
+ message=f"Nested archives are not allowed: {', '.join(nested_archives)}",
231
+ error_code=ErrorCode.ZIP_NESTED_ARCHIVE,
232
+ )
233
+
234
+ # Log analysis results
235
+ logger.debug(
236
+ "ZIP analysis: %s files, %sMB uncompressed, max ratio: %.1f:1, overall ratio: %.1f:1",
237
+ file_count,
238
+ total_uncompressed_size // (1024 * 1024),
239
+ max_compression_ratio,
240
+ overall_compression_ratio,
241
+ )
242
+
243
+ except zipfile.BadZipFile as err:
244
+ logger.error("Invalid or corrupted ZIP file", exc_info=True)
245
+ raise CompressionSecurityError(
246
+ message="Invalid or corrupted ZIP file",
247
+ error_code=ErrorCode.ZIP_CORRUPT,
248
+ ) from err
249
+ except zipfile.LargeZipFile as err:
250
+ logger.error("ZIP file too large to process", exc_info=True)
251
+ raise CompressionSecurityError(
252
+ message="ZIP file too large to process safely",
253
+ error_code=ErrorCode.ZIP_TOO_LARGE,
254
+ ) from err
255
+ except MemoryError as err:
256
+ logger.error("ZIP requires excessive memory", exc_info=True)
257
+ raise ZipBombError(
258
+ message="ZIP file requires too much memory to process - potential zip bomb",
259
+ compression_ratio=0,
260
+ ) from err
261
+ except (ZipBombError, CompressionSecurityError):
262
+ # Re-raise our own exceptions
263
+ raise
264
+ except Exception as err:
265
+ logger.error(
266
+ "Unexpected error during ZIP compression validation",
267
+ exc_info=True,
268
+ )
269
+ raise FileProcessingError(
270
+ message=f"ZIP validation failed: {str(err)}",
271
+ ) from err
272
+
273
+ def validate(self, file_content: bytes, compressed_size: int) -> None:
274
+ """
275
+ Validate the compression ratio of a ZIP file.
276
+
277
+ Args:
278
+ file_content: Raw bytes of the uploaded file.
279
+ compressed_size: Size of the file after compression in bytes.
280
+
281
+ Raises:
282
+ ZipBombError: If compression ratio exceeds maximum allowed.
283
+ CompressionSecurityError: If ZIP structure is invalid.
284
+ FileProcessingError: If unexpected error occurs.
285
+ """
286
+ return self.validate_zip_compression_ratio(file_content, compressed_size)
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .base import BaseValidator
7
+ from ..exceptions import ExtensionSecurityError, ErrorCode
8
+
9
+ if TYPE_CHECKING:
10
+ from ..config import FileSecurityConfig
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ExtensionSecurityValidator(BaseValidator):
17
+ """
18
+ Validates filenames against configured forbidden extensions.
19
+
20
+ Attributes:
21
+ config: File security configuration settings.
22
+ """
23
+
24
+ def __init__(self, config: FileSecurityConfig):
25
+ """
26
+ Initialize the validator.
27
+
28
+ Args:
29
+ config: File security configuration settings.
30
+ """
31
+ super().__init__(config)
32
+
33
+ def validate_extensions(self, filename: str) -> None:
34
+ """
35
+ Validate filename against blocked extensions.
36
+
37
+ Args:
38
+ filename: Name of the file to validate.
39
+
40
+ Raises:
41
+ ExtensionSecurityError: If blocked compound or single
42
+ extension detected in filename.
43
+ """
44
+ # Check for compound dangerous extensions first (e.g., .tar.xz, .user.js)
45
+ filename_lower = filename.lower()
46
+ for compound_ext in self.config.COMPOUND_BLOCKED_EXTENSIONS:
47
+ if filename_lower.endswith(compound_ext):
48
+ logger.warning(
49
+ "Dangerous compound extension detected",
50
+ extra={
51
+ "error_type": "compound_extension_blocked",
52
+ "file_name": filename,
53
+ "extension": compound_ext,
54
+ },
55
+ )
56
+ raise ExtensionSecurityError(
57
+ message=f"Dangerous compound file extension '{compound_ext}' detected in filename. "
58
+ f"Upload rejected for security.",
59
+ filename=filename,
60
+ extension=compound_ext,
61
+ error_code=ErrorCode.COMPOUND_EXTENSION_BLOCKED,
62
+ )
63
+
64
+ # Check ALL extensions in the filename for dangerous ones
65
+ parts = filename.split(".")
66
+ if len(parts) > 1:
67
+ for i in range(1, len(parts)):
68
+ ext = f".{parts[i].lower()}"
69
+ if ext in self.config.BLOCKED_EXTENSIONS:
70
+ logger.warning(
71
+ "Dangerous extension detected",
72
+ extra={
73
+ "error_type": "extension_blocked",
74
+ "file_name": filename,
75
+ "extension": ext,
76
+ },
77
+ )
78
+ raise ExtensionSecurityError(
79
+ message=f"Dangerous file extension '{ext}' detected in filename. "
80
+ f"Upload rejected for security.",
81
+ filename=filename,
82
+ extension=ext,
83
+ error_code=ErrorCode.EXTENSION_BLOCKED,
84
+ )
85
+
86
+ def validate(self, filename: str) -> None:
87
+ """
88
+ Validate the given filename.
89
+
90
+ Args:
91
+ filename: Name of the file to validate.
92
+
93
+ Raises:
94
+ ExtensionSecurityError: If filename extension is not
95
+ permitted.
96
+ """
97
+ return self.validate_extensions(filename)
@@ -0,0 +1,132 @@
1
+ """Unicode Security Validator Module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import unicodedata
6
+ import logging
7
+
8
+ from typing import TYPE_CHECKING
9
+ from .base import BaseValidator
10
+ from ..exceptions import UnicodeSecurityError
11
+
12
+ if TYPE_CHECKING:
13
+ from ..config import FileSecurityConfig
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class UnicodeSecurityValidator(BaseValidator):
20
+ """
21
+ Validates filenames for Unicode security threats.
22
+
23
+ Attributes:
24
+ config: Runtime configuration for file security rules.
25
+ """
26
+
27
+ def __init__(self, config: FileSecurityConfig):
28
+ """
29
+ Initialize the Unicode validator.
30
+
31
+ Args:
32
+ config: Runtime configuration that controls file security rules.
33
+ """
34
+ super().__init__(config)
35
+
36
+ def validate_unicode_security(self, filename: str) -> str:
37
+ """
38
+ Validate filename for unsafe Unicode characters.
39
+
40
+ Args:
41
+ filename: The filename to validate and normalize.
42
+
43
+ Returns:
44
+ The NFC-normalized filename.
45
+
46
+ Raises:
47
+ UnicodeSecurityError: If dangerous Unicode characters are
48
+ detected in the filename or result from normalization.
49
+ """
50
+ if not filename:
51
+ return filename
52
+
53
+ # Check for dangerous Unicode characters
54
+ dangerous_chars_found = []
55
+ for i, char in enumerate(filename):
56
+ char_code = ord(char)
57
+ if char_code in self.config.DANGEROUS_UNICODE_CHARS:
58
+ dangerous_chars_found.append((char, char_code, i))
59
+
60
+ # If dangerous characters found, reject the file entirely
61
+ if dangerous_chars_found:
62
+ char_details = []
63
+ for char, code, pos in dangerous_chars_found:
64
+ char_name = unicodedata.name(char, f"U+{code:04X}")
65
+ char_details.append(
66
+ f"'{char}' (U+{code:04X}: {char_name}) at position {pos}"
67
+ )
68
+
69
+ logger.warning(
70
+ "Dangerous Unicode characters detected",
71
+ extra={
72
+ "error_type": "unicode_security",
73
+ "file_name": filename,
74
+ "char_codes": [code for _, code, _ in dangerous_chars_found],
75
+ "positions": [pos for _, _, pos in dangerous_chars_found],
76
+ },
77
+ )
78
+ raise UnicodeSecurityError(
79
+ message=f"Dangerous Unicode characters detected in filename: {', '.join(char_details)}. "
80
+ f"These characters can be used to disguise file extensions or create security vulnerabilities.",
81
+ filename=filename,
82
+ dangerous_chars=dangerous_chars_found,
83
+ )
84
+
85
+ # Normalize Unicode to prevent normalization attacks
86
+ # Use NFC (Canonical Decomposition, followed by Canonical Composition)
87
+ # This prevents attacks where different Unicode representations of the same text are used
88
+ normalized_filename = unicodedata.normalize("NFC", filename)
89
+
90
+ # Check if normalization changed the filename significantly
91
+ if normalized_filename != filename:
92
+ logger.info(
93
+ "Unicode normalization applied: '%s' -> '%s'",
94
+ filename,
95
+ normalized_filename,
96
+ )
97
+
98
+ # Additional check: ensure normalized filename doesn't contain dangerous chars
99
+ # (some normalization attacks might introduce them)
100
+ for char in normalized_filename:
101
+ char_code = ord(char)
102
+ if char_code in self.config.DANGEROUS_UNICODE_CHARS:
103
+ char_name = unicodedata.name(char, f"U+{char_code:04X}")
104
+ logger.error(
105
+ "Unicode normalization resulted in dangerous character",
106
+ extra={
107
+ "error_type": "unicode_normalization_error",
108
+ "file_name": filename,
109
+ "normalized_filename": normalized_filename,
110
+ "char_code": char_code,
111
+ },
112
+ )
113
+ raise UnicodeSecurityError(
114
+ message=f"Unicode normalization resulted in dangerous character: "
115
+ f"'{char}' (U+{char_code:04X}: {char_name})",
116
+ filename=filename,
117
+ dangerous_chars=[(char, char_code, 0)],
118
+ )
119
+
120
+ return normalized_filename
121
+
122
+ def validate(self, filename: str) -> str:
123
+ """
124
+ Validate a filename for Unicode security issues.
125
+
126
+ Args:
127
+ filename: The name of the file to assess.
128
+
129
+ Returns:
130
+ The validated and normalized filename.
131
+ """
132
+ return self.validate_unicode_security(filename)
@@ -0,0 +1,95 @@
1
+ """Windows security validator for filename validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import logging
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .base import BaseValidator
10
+ from ..exceptions import WindowsReservedNameError
11
+
12
+ if TYPE_CHECKING:
13
+ from ..config import FileSecurityConfig
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class WindowsSecurityValidator(BaseValidator):
20
+ """
21
+ Validator for Windows reserved device names.
22
+
23
+ Attributes:
24
+ config: File security configuration settings.
25
+ """
26
+
27
+ def __init__(self, config: FileSecurityConfig):
28
+ """
29
+ Initialize the validator.
30
+
31
+ Args:
32
+ config: File security configuration settings.
33
+ """
34
+ super().__init__(config)
35
+
36
+ def validate_windows_reserved_names(self, filename: str) -> None:
37
+ """
38
+ Validate filename against Windows reserved device names.
39
+
40
+ Args:
41
+ filename: The filename to validate.
42
+
43
+ Raises:
44
+ WindowsReservedNameError: If filename matches a Windows
45
+ reserved device name.
46
+ """
47
+ # Check iteratively by removing extensions to handle compound extensions
48
+ # e.g., "CON.tar.gz" -> check "con.tar" and "con"
49
+ current_name = filename
50
+
51
+ while current_name:
52
+ # Get basename without extension
53
+ name_without_ext, ext = os.path.splitext(current_name)
54
+
55
+ # Normalize: lowercase, strip whitespace
56
+ name_to_check = name_without_ext.lower().strip()
57
+ # Remove leading dots to handle hidden files like ".CON.jpg"
58
+ name_to_check = name_to_check.lstrip(".")
59
+ # Remove trailing dots to handle cases like "con." or "con.."
60
+ name_to_check = name_to_check.rstrip(".")
61
+
62
+ if name_to_check in self.config.WINDOWS_RESERVED_NAMES:
63
+ logger.warning(
64
+ "Windows reserved name detected",
65
+ extra={
66
+ "error_type": "windows_reserved_name",
67
+ "file_name": filename,
68
+ "reserved_name": name_to_check.upper(),
69
+ },
70
+ )
71
+ raise WindowsReservedNameError(
72
+ message=f"Filename '{filename}' uses Windows reserved name '{name_to_check.upper()}'. "
73
+ f"Reserved names: {', '.join(sorted(self.config.WINDOWS_RESERVED_NAMES)).upper()}",
74
+ filename=filename,
75
+ reserved_name=name_to_check.upper(),
76
+ )
77
+
78
+ # If no extension was removed, we're done
79
+ if not ext or name_without_ext == current_name:
80
+ break
81
+
82
+ current_name = name_without_ext
83
+
84
+ def validate(self, filename: str) -> None:
85
+ """
86
+ Validate filename against Windows reserved naming rules.
87
+
88
+ Args:
89
+ filename: The filename to validate.
90
+
91
+ Raises:
92
+ WindowsReservedNameError: If filename matches a Windows
93
+ reserved device name.
94
+ """
95
+ return self.validate_windows_reserved_names(filename)