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,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)
|