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
safeuploads/config.py
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File security configuration module.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from .enums import (
|
|
9
|
+
DangerousExtensionCategory,
|
|
10
|
+
CompoundExtensionCategory,
|
|
11
|
+
UnicodeAttackCategory,
|
|
12
|
+
)
|
|
13
|
+
from .exceptions import ConfigValidationError, FileSecurityConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SecurityLimits:
|
|
21
|
+
"""
|
|
22
|
+
Security constraints for file submissions.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
max_image_size: Maximum size in bytes for image files.
|
|
26
|
+
max_zip_size: Maximum size in bytes for ZIP archives.
|
|
27
|
+
max_compression_ratio: Maximum expansion ratio for ZIP files.
|
|
28
|
+
max_uncompressed_size: Maximum cumulative size of ZIP contents.
|
|
29
|
+
max_individual_file_size: Maximum size of single file in ZIP.
|
|
30
|
+
max_zip_entries: Maximum number of file entries in ZIP.
|
|
31
|
+
zip_analysis_timeout: Maximum seconds for ZIP analysis.
|
|
32
|
+
max_zip_depth: Maximum directory nesting depth in ZIP.
|
|
33
|
+
max_filename_length: Maximum length for filenames in ZIP.
|
|
34
|
+
max_path_length: Maximum length for full paths in ZIP.
|
|
35
|
+
allow_nested_archives: Whether nested archives are permitted.
|
|
36
|
+
allow_symlinks: Whether symbolic links are permitted.
|
|
37
|
+
allow_absolute_paths: Whether absolute paths are permitted.
|
|
38
|
+
scan_zip_content: Whether deep content inspection is enabled.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# File size limits (in bytes)
|
|
42
|
+
max_image_size: int = 20 * 1024 * 1024 # 20MB for images
|
|
43
|
+
max_zip_size: int = 500 * 1024 * 1024 # 500MB for ZIP files
|
|
44
|
+
|
|
45
|
+
# ZIP compression security settings
|
|
46
|
+
max_compression_ratio: int = 100 # Maximum allowed expansion ratio (e.g., 100:1)
|
|
47
|
+
max_uncompressed_size: int = 1024 * 1024 * 1024 # 1GB max uncompressed size
|
|
48
|
+
max_individual_file_size: int = (
|
|
49
|
+
500 * 1024 * 1024
|
|
50
|
+
) # 500MB max per individual file in ZIP
|
|
51
|
+
max_zip_entries: int = 10000 # Maximum number of files in ZIP archive
|
|
52
|
+
zip_analysis_timeout: float = (
|
|
53
|
+
5.0 # Maximum seconds to spend analyzing ZIP structure
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# ZIP content inspection settings
|
|
57
|
+
max_zip_depth: int = 10 # Maximum nesting depth for directories in ZIP
|
|
58
|
+
max_filename_length: int = 255 # Maximum length for individual file names
|
|
59
|
+
max_path_length: int = 1024 # Maximum length for full file paths
|
|
60
|
+
allow_nested_archives: bool = False # Whether to allow nested archive files
|
|
61
|
+
allow_symlinks: bool = False # Whether to allow symbolic links in ZIP
|
|
62
|
+
allow_absolute_paths: bool = False # Whether to allow absolute paths in ZIP
|
|
63
|
+
scan_zip_content: bool = True # Whether to perform deep content inspection
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FileSecurityConfig:
|
|
67
|
+
"""
|
|
68
|
+
Centralizes file upload security settings and validation.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
limits: Security limits configuration instance.
|
|
72
|
+
ALLOWED_IMAGE_MIMES: Permitted MIME types for images.
|
|
73
|
+
ALLOWED_ZIP_MIMES: Permitted MIME types for ZIP files.
|
|
74
|
+
ALLOWED_IMAGE_EXTENSIONS: Permitted image file extensions.
|
|
75
|
+
ALLOWED_ZIP_EXTENSIONS: Permitted ZIP file extensions.
|
|
76
|
+
BLOCKED_EXTENSIONS: Dangerous file extensions to block.
|
|
77
|
+
COMPOUND_BLOCKED_EXTENSIONS: Multi-part extensions to block.
|
|
78
|
+
DANGEROUS_UNICODE_CHARS: Unicode characters for filename attacks.
|
|
79
|
+
WINDOWS_RESERVED_NAMES: Platform-specific reserved filenames.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# Security limits configuration
|
|
83
|
+
limits = SecurityLimits()
|
|
84
|
+
|
|
85
|
+
# Allowed MIME types for images
|
|
86
|
+
ALLOWED_IMAGE_MIMES: set[str] = {"image/jpeg", "image/jpg", "image/png"}
|
|
87
|
+
|
|
88
|
+
# Allowed MIME types for ZIP files
|
|
89
|
+
ALLOWED_ZIP_MIMES: set[str] = {
|
|
90
|
+
"application/zip",
|
|
91
|
+
"application/x-zip-compressed",
|
|
92
|
+
"multipart/x-zip",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Allowed file extensions
|
|
96
|
+
ALLOWED_IMAGE_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png"}
|
|
97
|
+
ALLOWED_ZIP_EXTENSIONS: set[str] = {".zip"}
|
|
98
|
+
|
|
99
|
+
# Generate dangerous file extensions from categorized enums
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _generate_blocked_extensions() -> set[str]:
|
|
102
|
+
"""
|
|
103
|
+
Aggregate all dangerous extension categories.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Combined set of blocked file extensions.
|
|
107
|
+
"""
|
|
108
|
+
blocked_extensions = set()
|
|
109
|
+
|
|
110
|
+
# Combine all dangerous extension categories
|
|
111
|
+
for category in DangerousExtensionCategory:
|
|
112
|
+
blocked_extensions.update(category.value)
|
|
113
|
+
|
|
114
|
+
return blocked_extensions
|
|
115
|
+
|
|
116
|
+
# Generate compound dangerous file extensions from categorized enums
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _generate_compound_blocked_extensions() -> set[str]:
|
|
119
|
+
"""
|
|
120
|
+
Aggregate all compound extension categories.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Combined set of blocked compound file extensions.
|
|
124
|
+
"""
|
|
125
|
+
compound_extensions = set()
|
|
126
|
+
|
|
127
|
+
# Combine all compound extension categories
|
|
128
|
+
for category in CompoundExtensionCategory:
|
|
129
|
+
compound_extensions.update(category.value)
|
|
130
|
+
|
|
131
|
+
return compound_extensions
|
|
132
|
+
|
|
133
|
+
# Generate dangerous Unicode characters from categorized enums
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _generate_dangerous_unicode_chars() -> set[int]:
|
|
136
|
+
"""
|
|
137
|
+
Aggregate all dangerous Unicode code points.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Combined set of dangerous Unicode code points.
|
|
141
|
+
"""
|
|
142
|
+
dangerous_chars = set()
|
|
143
|
+
|
|
144
|
+
# Combine all Unicode attack categories
|
|
145
|
+
for category in UnicodeAttackCategory:
|
|
146
|
+
dangerous_chars.update(category.value)
|
|
147
|
+
|
|
148
|
+
return dangerous_chars
|
|
149
|
+
|
|
150
|
+
# Dangerous file extensions to explicitly block (generated from enums)
|
|
151
|
+
BLOCKED_EXTENSIONS: set[str] = _generate_blocked_extensions()
|
|
152
|
+
|
|
153
|
+
# Compound dangerous file extensions (multi-part extensions)
|
|
154
|
+
# These are checked as complete strings, not individual parts
|
|
155
|
+
COMPOUND_BLOCKED_EXTENSIONS: set[str] = _generate_compound_blocked_extensions()
|
|
156
|
+
|
|
157
|
+
# Dangerous Unicode characters that can be used for filename attacks
|
|
158
|
+
# These characters can disguise file extensions or cause rendering issues
|
|
159
|
+
DANGEROUS_UNICODE_CHARS: set[int] = _generate_dangerous_unicode_chars()
|
|
160
|
+
|
|
161
|
+
# Windows reserved names that cannot be used as filenames
|
|
162
|
+
# These names are reserved by Windows regardless of extension
|
|
163
|
+
WINDOWS_RESERVED_NAMES: set[str] = {
|
|
164
|
+
"con",
|
|
165
|
+
"prn",
|
|
166
|
+
"aux",
|
|
167
|
+
"nul",
|
|
168
|
+
"com1",
|
|
169
|
+
"com2",
|
|
170
|
+
"com3",
|
|
171
|
+
"com4",
|
|
172
|
+
"com5",
|
|
173
|
+
"com6",
|
|
174
|
+
"com7",
|
|
175
|
+
"com8",
|
|
176
|
+
"com9",
|
|
177
|
+
"lpt1",
|
|
178
|
+
"lpt2",
|
|
179
|
+
"lpt3",
|
|
180
|
+
"lpt4",
|
|
181
|
+
"lpt5",
|
|
182
|
+
"lpt6",
|
|
183
|
+
"lpt7",
|
|
184
|
+
"lpt8",
|
|
185
|
+
"lpt9",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Configuration validation trigger
|
|
189
|
+
@classmethod
|
|
190
|
+
def __init_subclass__(cls, **kwargs):
|
|
191
|
+
"""
|
|
192
|
+
Hook for subclass creation to validate configuration.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
**kwargs: Subclass initialization arguments.
|
|
196
|
+
"""
|
|
197
|
+
super().__init_subclass__(**kwargs)
|
|
198
|
+
# Perform validation with warnings allowed (non-strict mode)
|
|
199
|
+
try:
|
|
200
|
+
cls.validate_and_report(strict=False)
|
|
201
|
+
except Exception as err:
|
|
202
|
+
logger.warning("Configuration validation failed: %s", err)
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def get_extensions_by_category(
|
|
206
|
+
cls, category: DangerousExtensionCategory
|
|
207
|
+
) -> set[str]:
|
|
208
|
+
"""
|
|
209
|
+
Return extensions for a dangerous extension category.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
category: The dangerous extension category.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Copy of extensions in the specified category.
|
|
216
|
+
"""
|
|
217
|
+
return category.value.copy()
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def get_compound_extensions_by_category(
|
|
221
|
+
cls, category: CompoundExtensionCategory
|
|
222
|
+
) -> set[str]:
|
|
223
|
+
"""
|
|
224
|
+
Return compound extensions for a category.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
category: The compound extension category.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Copy of compound extensions in the specified category.
|
|
231
|
+
"""
|
|
232
|
+
return category.value.copy()
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def get_unicode_chars_by_category(cls, category: UnicodeAttackCategory) -> set[int]:
|
|
236
|
+
"""
|
|
237
|
+
Return Unicode code points for an attack category.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
category: The Unicode attack category.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Copy of code points in the specified category.
|
|
244
|
+
"""
|
|
245
|
+
return category.value.copy()
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def is_extension_in_category(
|
|
249
|
+
cls, extension: str, category: DangerousExtensionCategory
|
|
250
|
+
) -> bool:
|
|
251
|
+
"""
|
|
252
|
+
Check if extension belongs to a dangerous category.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
extension: File extension to evaluate.
|
|
256
|
+
category: Category to check against.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if extension is in the category, False otherwise.
|
|
260
|
+
"""
|
|
261
|
+
return extension.lower() in category.value
|
|
262
|
+
|
|
263
|
+
@classmethod
|
|
264
|
+
def get_extension_category(
|
|
265
|
+
cls, extension: str
|
|
266
|
+
) -> DangerousExtensionCategory | None:
|
|
267
|
+
"""
|
|
268
|
+
Return the dangerous extension category for an extension.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
extension: The file extension to evaluate.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Matching category if dangerous, None otherwise.
|
|
275
|
+
"""
|
|
276
|
+
extension_lower = extension.lower()
|
|
277
|
+
for category in DangerousExtensionCategory:
|
|
278
|
+
if extension_lower in category.value:
|
|
279
|
+
return category
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def validate_configuration(cls, strict: bool = True) -> list[ConfigValidationError]:
|
|
284
|
+
"""
|
|
285
|
+
Run all configuration validation routines.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
strict: Reserved for future behavior adjustments.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
List of detected validation errors.
|
|
292
|
+
"""
|
|
293
|
+
errors = []
|
|
294
|
+
|
|
295
|
+
# Validate file size limits
|
|
296
|
+
errors.extend(cls._validate_file_size_limits())
|
|
297
|
+
|
|
298
|
+
# Validate MIME type configurations
|
|
299
|
+
errors.extend(cls._validate_mime_configurations())
|
|
300
|
+
|
|
301
|
+
# Validate file extension configurations
|
|
302
|
+
errors.extend(cls._validate_extension_configurations())
|
|
303
|
+
|
|
304
|
+
# Validate ZIP compression settings
|
|
305
|
+
errors.extend(cls._validate_compression_settings())
|
|
306
|
+
|
|
307
|
+
# Validate enum consistency
|
|
308
|
+
errors.extend(cls._validate_enum_consistency())
|
|
309
|
+
|
|
310
|
+
# Validate cross-configuration dependencies
|
|
311
|
+
errors.extend(cls._validate_cross_dependencies())
|
|
312
|
+
|
|
313
|
+
return errors
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def _validate_file_size_limits(cls) -> list[ConfigValidationError]:
|
|
317
|
+
"""
|
|
318
|
+
Validate configured file size limits.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
List of detected configuration issues.
|
|
322
|
+
"""
|
|
323
|
+
errors = []
|
|
324
|
+
|
|
325
|
+
# Check image size limits
|
|
326
|
+
if cls.limits.max_image_size <= 0:
|
|
327
|
+
errors.append(
|
|
328
|
+
ConfigValidationError(
|
|
329
|
+
error_type="invalid_size_limit",
|
|
330
|
+
message="max_image_size must be greater than 0",
|
|
331
|
+
severity="error",
|
|
332
|
+
component="file_sizes",
|
|
333
|
+
recommendation="Set max_image_size to a positive value (e.g., 20MB)",
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
if cls.limits.max_image_size > 100 * 1024 * 1024: # 100MB
|
|
338
|
+
errors.append(
|
|
339
|
+
ConfigValidationError(
|
|
340
|
+
error_type="excessive_size_limit",
|
|
341
|
+
message=f"max_image_size ({cls.limits.max_image_size // (1024*1024)}MB) is very large",
|
|
342
|
+
severity="warning",
|
|
343
|
+
component="file_sizes",
|
|
344
|
+
recommendation="Consider reducing image size limit to prevent resource exhaustion",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Check ZIP size limits
|
|
349
|
+
if cls.limits.max_zip_size <= 0:
|
|
350
|
+
errors.append(
|
|
351
|
+
ConfigValidationError(
|
|
352
|
+
error_type="invalid_size_limit",
|
|
353
|
+
message="max_zip_size must be greater than 0",
|
|
354
|
+
severity="error",
|
|
355
|
+
component="file_sizes",
|
|
356
|
+
recommendation="Set max_zip_size to a positive value (e.g., 500MB)",
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if cls.limits.max_zip_size > 2 * 1024 * 1024 * 1024: # 2GB
|
|
361
|
+
errors.append(
|
|
362
|
+
ConfigValidationError(
|
|
363
|
+
error_type="excessive_size_limit",
|
|
364
|
+
message=f"max_zip_size ({cls.limits.max_zip_size // (1024*1024)}MB) is very large",
|
|
365
|
+
severity="warning",
|
|
366
|
+
component="file_sizes",
|
|
367
|
+
recommendation="Consider reducing ZIP size limit to prevent resource exhaustion",
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Validate size relationship
|
|
372
|
+
if cls.limits.max_zip_size <= cls.limits.max_image_size:
|
|
373
|
+
errors.append(
|
|
374
|
+
ConfigValidationError(
|
|
375
|
+
error_type="inconsistent_size_limits",
|
|
376
|
+
message="max_zip_size should typically be larger than max_image_size",
|
|
377
|
+
severity="warning",
|
|
378
|
+
component="file_sizes",
|
|
379
|
+
recommendation="ZIP files usually contain multiple files and should have higher limits",
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return errors
|
|
384
|
+
|
|
385
|
+
@classmethod
|
|
386
|
+
def _validate_mime_configurations(cls) -> list[ConfigValidationError]:
|
|
387
|
+
"""
|
|
388
|
+
Validate MIME type configurations.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
List of detected configuration issues.
|
|
392
|
+
"""
|
|
393
|
+
errors = []
|
|
394
|
+
|
|
395
|
+
# Check image MIME types
|
|
396
|
+
if not cls.ALLOWED_IMAGE_MIMES:
|
|
397
|
+
errors.append(
|
|
398
|
+
ConfigValidationError(
|
|
399
|
+
error_type="empty_mime_set",
|
|
400
|
+
message="ALLOWED_IMAGE_MIMES cannot be empty",
|
|
401
|
+
severity="error",
|
|
402
|
+
component="mime_types",
|
|
403
|
+
recommendation="Add at least one allowed image MIME type",
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Validate image MIME type format
|
|
408
|
+
for mime_type in cls.ALLOWED_IMAGE_MIMES:
|
|
409
|
+
if not mime_type.startswith("image/"):
|
|
410
|
+
errors.append(
|
|
411
|
+
ConfigValidationError(
|
|
412
|
+
error_type="invalid_image_mime",
|
|
413
|
+
message=f"Image MIME type '{mime_type}' should start with 'image/'",
|
|
414
|
+
severity="warning",
|
|
415
|
+
component="mime_types",
|
|
416
|
+
recommendation="Use standard image MIME types like 'image/jpeg', 'image/png'",
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Check ZIP MIME types
|
|
421
|
+
if not cls.ALLOWED_ZIP_MIMES:
|
|
422
|
+
errors.append(
|
|
423
|
+
ConfigValidationError(
|
|
424
|
+
error_type="empty_mime_set",
|
|
425
|
+
message="ALLOWED_ZIP_MIMES cannot be empty",
|
|
426
|
+
severity="error",
|
|
427
|
+
component="mime_types",
|
|
428
|
+
recommendation="Add at least one allowed ZIP MIME type",
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Check for duplicate MIME types
|
|
433
|
+
all_mimes = list(cls.ALLOWED_IMAGE_MIMES) + list(cls.ALLOWED_ZIP_MIMES)
|
|
434
|
+
duplicates = set([mime for mime in all_mimes if all_mimes.count(mime) > 1])
|
|
435
|
+
if duplicates:
|
|
436
|
+
errors.append(
|
|
437
|
+
ConfigValidationError(
|
|
438
|
+
error_type="duplicate_mime_types",
|
|
439
|
+
message=f"Duplicate MIME types found: {duplicates}",
|
|
440
|
+
severity="warning",
|
|
441
|
+
component="mime_types",
|
|
442
|
+
recommendation="Remove duplicate MIME types to avoid confusion",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
return errors
|
|
447
|
+
|
|
448
|
+
@classmethod
|
|
449
|
+
def _validate_extension_configurations(cls) -> list[ConfigValidationError]:
|
|
450
|
+
"""
|
|
451
|
+
Validate file extension configurations.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
List of detected configuration issues.
|
|
455
|
+
"""
|
|
456
|
+
errors = []
|
|
457
|
+
|
|
458
|
+
# Check extension format
|
|
459
|
+
for ext_set_name, ext_set in [
|
|
460
|
+
("ALLOWED_IMAGE_EXTENSIONS", cls.ALLOWED_IMAGE_EXTENSIONS),
|
|
461
|
+
("ALLOWED_ZIP_EXTENSIONS", cls.ALLOWED_ZIP_EXTENSIONS),
|
|
462
|
+
]:
|
|
463
|
+
if not ext_set:
|
|
464
|
+
errors.append(
|
|
465
|
+
ConfigValidationError(
|
|
466
|
+
error_type="empty_extension_set",
|
|
467
|
+
message=f"{ext_set_name} cannot be empty",
|
|
468
|
+
severity="error",
|
|
469
|
+
component="extensions",
|
|
470
|
+
recommendation=f"Add at least one extension to {ext_set_name}",
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
for ext in ext_set:
|
|
475
|
+
if not ext.startswith("."):
|
|
476
|
+
errors.append(
|
|
477
|
+
ConfigValidationError(
|
|
478
|
+
error_type="invalid_extension_format",
|
|
479
|
+
message=f"Extension '{ext}' in {ext_set_name} should start with '.'",
|
|
480
|
+
severity="error",
|
|
481
|
+
component="extensions",
|
|
482
|
+
recommendation="Use format '.ext' for file extensions",
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Check blocked extensions
|
|
487
|
+
if not cls.BLOCKED_EXTENSIONS:
|
|
488
|
+
errors.append(
|
|
489
|
+
ConfigValidationError(
|
|
490
|
+
error_type="empty_blocked_extensions",
|
|
491
|
+
message="BLOCKED_EXTENSIONS is empty - security risk",
|
|
492
|
+
severity="error",
|
|
493
|
+
component="extensions",
|
|
494
|
+
recommendation="Ensure dangerous extensions are properly blocked",
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Check for overlap between allowed and blocked extensions
|
|
499
|
+
image_blocked = cls.ALLOWED_IMAGE_EXTENSIONS.intersection(
|
|
500
|
+
cls.BLOCKED_EXTENSIONS
|
|
501
|
+
)
|
|
502
|
+
if image_blocked:
|
|
503
|
+
errors.append(
|
|
504
|
+
ConfigValidationError(
|
|
505
|
+
error_type="extension_conflict",
|
|
506
|
+
message=f"Image extensions {image_blocked} are both allowed and blocked",
|
|
507
|
+
severity="error",
|
|
508
|
+
component="extensions",
|
|
509
|
+
recommendation="Remove conflicts between allowed and blocked extensions",
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
zip_blocked = cls.ALLOWED_ZIP_EXTENSIONS.intersection(cls.BLOCKED_EXTENSIONS)
|
|
514
|
+
if zip_blocked:
|
|
515
|
+
errors.append(
|
|
516
|
+
ConfigValidationError(
|
|
517
|
+
error_type="extension_conflict",
|
|
518
|
+
message=f"ZIP extensions {zip_blocked} are both allowed and blocked",
|
|
519
|
+
severity="error",
|
|
520
|
+
component="extensions",
|
|
521
|
+
recommendation="Remove conflicts between allowed and blocked extensions",
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Check compound extension consistency
|
|
526
|
+
compound_overlap = cls.BLOCKED_EXTENSIONS.intersection(
|
|
527
|
+
cls.COMPOUND_BLOCKED_EXTENSIONS
|
|
528
|
+
)
|
|
529
|
+
if compound_overlap:
|
|
530
|
+
errors.append(
|
|
531
|
+
ConfigValidationError(
|
|
532
|
+
error_type="compound_extension_overlap",
|
|
533
|
+
message=f"Extensions {compound_overlap} appear in both blocked and compound blocked lists",
|
|
534
|
+
severity="warning",
|
|
535
|
+
component="extensions",
|
|
536
|
+
recommendation="Compound extensions should only be in COMPOUND_BLOCKED_EXTENSIONS",
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
return errors
|
|
541
|
+
|
|
542
|
+
@classmethod
|
|
543
|
+
def _validate_compression_settings(cls) -> list[ConfigValidationError]:
|
|
544
|
+
"""
|
|
545
|
+
Validate compression-related limits.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
List of detected configuration issues.
|
|
549
|
+
"""
|
|
550
|
+
errors = []
|
|
551
|
+
|
|
552
|
+
# Validate compression ratio
|
|
553
|
+
if cls.limits.max_compression_ratio <= 0:
|
|
554
|
+
errors.append(
|
|
555
|
+
ConfigValidationError(
|
|
556
|
+
error_type="invalid_compression_ratio",
|
|
557
|
+
message="max_compression_ratio must be greater than 0",
|
|
558
|
+
severity="error",
|
|
559
|
+
component="compression",
|
|
560
|
+
recommendation="Set a reasonable compression ratio limit (e.g., 100:1)",
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
if cls.limits.max_compression_ratio < 10:
|
|
565
|
+
errors.append(
|
|
566
|
+
ConfigValidationError(
|
|
567
|
+
error_type="too_strict_compression",
|
|
568
|
+
message=f"max_compression_ratio ({cls.limits.max_compression_ratio}) is very strict",
|
|
569
|
+
severity="warning",
|
|
570
|
+
component="compression",
|
|
571
|
+
recommendation="Consider allowing higher compression ratios for legitimate files",
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
if cls.limits.max_compression_ratio > 1000:
|
|
576
|
+
errors.append(
|
|
577
|
+
ConfigValidationError(
|
|
578
|
+
error_type="too_permissive_compression",
|
|
579
|
+
message=f"max_compression_ratio ({cls.limits.max_compression_ratio}) may allow zip bombs",
|
|
580
|
+
severity="warning",
|
|
581
|
+
component="compression",
|
|
582
|
+
recommendation="Reduce compression ratio limit to prevent zip bomb attacks",
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# Validate uncompressed size limit
|
|
587
|
+
if cls.limits.max_uncompressed_size <= 0:
|
|
588
|
+
errors.append(
|
|
589
|
+
ConfigValidationError(
|
|
590
|
+
error_type="invalid_uncompressed_size",
|
|
591
|
+
message="max_uncompressed_size must be greater than 0",
|
|
592
|
+
severity="error",
|
|
593
|
+
component="compression",
|
|
594
|
+
recommendation="Set a reasonable uncompressed size limit",
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# Validate individual file size limit
|
|
599
|
+
if cls.limits.max_individual_file_size <= 0:
|
|
600
|
+
errors.append(
|
|
601
|
+
ConfigValidationError(
|
|
602
|
+
error_type="invalid_individual_file_size",
|
|
603
|
+
message="max_individual_file_size must be greater than 0",
|
|
604
|
+
severity="error",
|
|
605
|
+
component="compression",
|
|
606
|
+
recommendation="Set a reasonable individual file size limit",
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Check individual file size doesn't exceed total uncompressed size
|
|
611
|
+
if cls.limits.max_individual_file_size > cls.limits.max_uncompressed_size:
|
|
612
|
+
errors.append(
|
|
613
|
+
ConfigValidationError(
|
|
614
|
+
error_type="inconsistent_size_limits",
|
|
615
|
+
message=f"max_individual_file_size ({cls.limits.max_individual_file_size // (1024*1024)}MB) "
|
|
616
|
+
f"exceeds max_uncompressed_size ({cls.limits.max_uncompressed_size // (1024*1024)}MB)",
|
|
617
|
+
severity="warning",
|
|
618
|
+
component="compression",
|
|
619
|
+
recommendation="Individual file size limit should not exceed total uncompressed size limit",
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
# Validate ZIP entry limits
|
|
624
|
+
if cls.limits.max_zip_entries <= 0:
|
|
625
|
+
errors.append(
|
|
626
|
+
ConfigValidationError(
|
|
627
|
+
error_type="invalid_zip_entries",
|
|
628
|
+
message="max_zip_entries must be greater than 0",
|
|
629
|
+
severity="error",
|
|
630
|
+
component="compression",
|
|
631
|
+
recommendation="Set a reasonable limit for ZIP file entries",
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if cls.limits.max_zip_entries > 100000:
|
|
636
|
+
errors.append(
|
|
637
|
+
ConfigValidationError(
|
|
638
|
+
error_type="excessive_zip_entries",
|
|
639
|
+
message=f"max_zip_entries ({cls.limits.max_zip_entries}) is very high",
|
|
640
|
+
severity="warning",
|
|
641
|
+
component="compression",
|
|
642
|
+
recommendation="High entry limits may impact performance",
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# Validate timeout settings
|
|
647
|
+
if cls.limits.zip_analysis_timeout <= 0:
|
|
648
|
+
errors.append(
|
|
649
|
+
ConfigValidationError(
|
|
650
|
+
error_type="invalid_timeout",
|
|
651
|
+
message="zip_analysis_timeout must be greater than 0",
|
|
652
|
+
severity="error",
|
|
653
|
+
component="compression",
|
|
654
|
+
recommendation="Set a reasonable timeout for ZIP analysis",
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
if cls.limits.zip_analysis_timeout > 30:
|
|
659
|
+
errors.append(
|
|
660
|
+
ConfigValidationError(
|
|
661
|
+
error_type="excessive_timeout",
|
|
662
|
+
message=f"zip_analysis_timeout ({cls.limits.zip_analysis_timeout}s) is very long",
|
|
663
|
+
severity="warning",
|
|
664
|
+
component="compression",
|
|
665
|
+
recommendation="Long timeouts may impact user experience",
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
return errors
|
|
670
|
+
|
|
671
|
+
@classmethod
|
|
672
|
+
def _validate_enum_consistency(cls) -> list[ConfigValidationError]:
|
|
673
|
+
"""
|
|
674
|
+
Validate enum categories for emptiness and overlaps.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
List of detected configuration issues.
|
|
678
|
+
"""
|
|
679
|
+
errors = []
|
|
680
|
+
|
|
681
|
+
# Check for empty enum categories
|
|
682
|
+
for category in DangerousExtensionCategory:
|
|
683
|
+
if not category.value:
|
|
684
|
+
errors.append(
|
|
685
|
+
ConfigValidationError(
|
|
686
|
+
error_type="empty_enum_category",
|
|
687
|
+
message=f"Extension category {category.name} is empty",
|
|
688
|
+
severity="warning",
|
|
689
|
+
component="enums",
|
|
690
|
+
recommendation=f"Add extensions to {category.name} or remove unused category",
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
for category in CompoundExtensionCategory:
|
|
695
|
+
if not category.value:
|
|
696
|
+
errors.append(
|
|
697
|
+
ConfigValidationError(
|
|
698
|
+
error_type="empty_enum_category",
|
|
699
|
+
message=f"Compound extension category {category.name} is empty",
|
|
700
|
+
severity="warning",
|
|
701
|
+
component="enums",
|
|
702
|
+
recommendation=f"Add extensions to {category.name} or remove unused category",
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
for category in UnicodeAttackCategory:
|
|
707
|
+
if not category.value:
|
|
708
|
+
errors.append(
|
|
709
|
+
ConfigValidationError(
|
|
710
|
+
error_type="empty_enum_category",
|
|
711
|
+
message=f"Unicode attack category {category.name} is empty",
|
|
712
|
+
severity="warning",
|
|
713
|
+
component="enums",
|
|
714
|
+
recommendation=f"Add Unicode characters to {category.name} or remove unused category",
|
|
715
|
+
)
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Check for overlapping extensions between categories
|
|
719
|
+
all_extensions_by_category = {}
|
|
720
|
+
for category in DangerousExtensionCategory:
|
|
721
|
+
all_extensions_by_category[category.name] = category.value
|
|
722
|
+
|
|
723
|
+
for cat1_name, cat1_exts in all_extensions_by_category.items():
|
|
724
|
+
for cat2_name, cat2_exts in all_extensions_by_category.items():
|
|
725
|
+
if cat1_name != cat2_name:
|
|
726
|
+
overlap = cat1_exts.intersection(cat2_exts)
|
|
727
|
+
if overlap:
|
|
728
|
+
errors.append(
|
|
729
|
+
ConfigValidationError(
|
|
730
|
+
error_type="category_overlap",
|
|
731
|
+
message=f"Categories {cat1_name} and {cat2_name} share extensions: {overlap}",
|
|
732
|
+
severity="info",
|
|
733
|
+
component="enums",
|
|
734
|
+
recommendation="Consider if extensions should belong to multiple categories",
|
|
735
|
+
)
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
return errors
|
|
739
|
+
|
|
740
|
+
@classmethod
|
|
741
|
+
def _validate_cross_dependencies(cls) -> list[ConfigValidationError]:
|
|
742
|
+
"""
|
|
743
|
+
Validate cross-field configuration constraints.
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
List of detected configuration issues.
|
|
747
|
+
"""
|
|
748
|
+
errors = []
|
|
749
|
+
|
|
750
|
+
# Check Windows reserved names format
|
|
751
|
+
for name in cls.WINDOWS_RESERVED_NAMES:
|
|
752
|
+
if not name.islower():
|
|
753
|
+
errors.append(
|
|
754
|
+
ConfigValidationError(
|
|
755
|
+
error_type="case_sensitive_reserved_name",
|
|
756
|
+
message=f"Windows reserved name '{name}' should be lowercase",
|
|
757
|
+
severity="warning",
|
|
758
|
+
component="reserved_names",
|
|
759
|
+
recommendation="Use lowercase for consistent case-insensitive matching",
|
|
760
|
+
)
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# Validate Unicode character ranges
|
|
764
|
+
for char_code in cls.DANGEROUS_UNICODE_CHARS:
|
|
765
|
+
if not isinstance(char_code, int):
|
|
766
|
+
errors.append(
|
|
767
|
+
ConfigValidationError(
|
|
768
|
+
error_type="invalid_unicode_char",
|
|
769
|
+
message=f"Unicode character code {char_code} is not an integer",
|
|
770
|
+
severity="error",
|
|
771
|
+
component="unicode",
|
|
772
|
+
recommendation="Use integer Unicode code points",
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
elif char_code < 0 or char_code > 0x10FFFF:
|
|
776
|
+
errors.append(
|
|
777
|
+
ConfigValidationError(
|
|
778
|
+
error_type="invalid_unicode_range",
|
|
779
|
+
message=f"Unicode character code {char_code} is outside valid range",
|
|
780
|
+
severity="error",
|
|
781
|
+
component="unicode",
|
|
782
|
+
recommendation="Use valid Unicode code points (0-0x10FFFF)",
|
|
783
|
+
)
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
return errors
|
|
787
|
+
|
|
788
|
+
@classmethod
|
|
789
|
+
def validate_and_report(cls, strict: bool = True) -> None:
|
|
790
|
+
"""
|
|
791
|
+
Validate configuration and log outcomes.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
strict: If True, raise on errors/warnings.
|
|
795
|
+
|
|
796
|
+
Raises:
|
|
797
|
+
FileSecurityConfigurationError: If strict and issues found.
|
|
798
|
+
"""
|
|
799
|
+
errors = cls.validate_configuration(strict=strict)
|
|
800
|
+
|
|
801
|
+
if not errors:
|
|
802
|
+
logger.info("File security configuration validation passed")
|
|
803
|
+
return
|
|
804
|
+
|
|
805
|
+
# Separate errors by severity
|
|
806
|
+
error_list = [e for e in errors if e.severity == "error"]
|
|
807
|
+
warning_list = [e for e in errors if e.severity == "warning"]
|
|
808
|
+
info_list = [e for e in errors if e.severity == "info"]
|
|
809
|
+
|
|
810
|
+
# Log validation results
|
|
811
|
+
if error_list:
|
|
812
|
+
for error in error_list:
|
|
813
|
+
logger.error(
|
|
814
|
+
"Configuration error in %s: %s. %s",
|
|
815
|
+
error.component,
|
|
816
|
+
error.message,
|
|
817
|
+
error.recommendation,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
if warning_list:
|
|
821
|
+
for warning in warning_list:
|
|
822
|
+
logger.warning(
|
|
823
|
+
"Configuration warning in %s: %s. %s",
|
|
824
|
+
warning.component,
|
|
825
|
+
warning.message,
|
|
826
|
+
warning.recommendation,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
if info_list:
|
|
830
|
+
for info in info_list:
|
|
831
|
+
logger.info(
|
|
832
|
+
"Configuration info in %s: %s. %s",
|
|
833
|
+
info.component,
|
|
834
|
+
info.message,
|
|
835
|
+
info.recommendation,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
# Raise exception if there are errors and strict mode is enabled
|
|
839
|
+
if error_list and strict:
|
|
840
|
+
raise FileSecurityConfigurationError(error_list)
|
|
841
|
+
elif (error_list or warning_list) and strict:
|
|
842
|
+
raise FileSecurityConfigurationError(error_list + warning_list)
|