Skip to content

Commit 4c51831

Browse files
Merge pull request steam-bell-92#1378 from Mohanapriya-sparks/feature/security-validation-framework
feat: Add centralized security and input validation framework (Fixes steam-bell-92#1344)
2 parents be352e7 + b963338 commit 4c51831

5 files changed

Lines changed: 721 additions & 0 deletions

File tree

security/__init__.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""
2+
Security module with centralized validation and sanitization.
3+
4+
This module provides secure wrappers for vulnerable standard library functions:
5+
- URL validation (addressing CVE-2025-0938)
6+
- Safe tar extraction (addressing CVE-2025-4435)
7+
"""
8+
9+
from .exceptions import (
10+
SecurityValidationError,
11+
InvalidURLError,
12+
UnsafeTarError,
13+
SecurityWarning,
14+
)
15+
16+
from .url_sanitizer import (
17+
URLSanitizer,
18+
validate_url,
19+
)
20+
21+
from .tar_safe import (
22+
SafeTarExtractor,
23+
safe_extract,
24+
)
25+
26+
__all__ = [
27+
'SecurityValidationError',
28+
'InvalidURLError',
29+
'UnsafeTarError',
30+
'SecurityWarning',
31+
'URLSanitizer',
32+
'validate_url',
33+
'SafeTarExtractor',
34+
'safe_extract',
35+
]
36+
37+
__version__ = '1.0.0'

security/exceptions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Custom exceptions for the security module.
3+
"""
4+
5+
6+
class SecurityValidationError(Exception):
7+
"""Base exception for security validation errors."""
8+
pass
9+
10+
11+
class InvalidURLError(SecurityValidationError):
12+
"""Raised when URL validation fails."""
13+
pass
14+
15+
16+
class UnsafeTarError(SecurityValidationError):
17+
"""Raised when tar extraction is unsafe."""
18+
pass
19+
20+
21+
class SecurityWarning(Warning):
22+
"""Warning for security-related issues."""
23+
pass

security/tar_safe.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
"""
2+
Safe tar file extraction wrapper.
3+
4+
Addresses CVE-2025-4435 (tarfile)
5+
"""
6+
7+
import tarfile
8+
import os
9+
import tempfile
10+
from pathlib import Path
11+
from typing import Optional, Union, List
12+
13+
from .exceptions import UnsafeTarError, SecurityWarning
14+
import warnings
15+
16+
17+
class SafeTarExtractor:
18+
"""Safe tar file extractor with security protections."""
19+
20+
DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
21+
DEFAULT_MAX_FILES = 1000
22+
23+
def __init__(
24+
self,
25+
max_file_size: int = DEFAULT_MAX_FILE_SIZE,
26+
max_files: int = DEFAULT_MAX_FILES,
27+
preserve_permissions: bool = False,
28+
allow_absolute_paths: bool = False,
29+
block_devices: bool = True,
30+
):
31+
self.max_file_size = max_file_size
32+
self.max_files = max_files
33+
self.preserve_permissions = preserve_permissions
34+
self.allow_absolute_paths = allow_absolute_paths
35+
self.block_devices = block_devices
36+
37+
self.blocked_types = set()
38+
if block_devices:
39+
self.blocked_types.update({
40+
tarfile.BLKTYPE,
41+
tarfile.CHRTYPE,
42+
tarfile.FIFOTYPE,
43+
})
44+
45+
def extract(
46+
self,
47+
tar_path: Union[str, Path],
48+
extract_path: Union[str, Path],
49+
overwrite: bool = False,
50+
) -> List[Path]:
51+
"""Safely extract a tar file."""
52+
tar_path = Path(tar_path)
53+
extract_path = Path(extract_path)
54+
55+
if not tar_path.exists():
56+
raise FileNotFoundError(f"Tar file not found: {tar_path}")
57+
58+
extract_path.mkdir(parents=True, exist_ok=True)
59+
60+
extracted_files = []
61+
file_count = 0
62+
63+
try:
64+
with tarfile.open(tar_path, 'r:*') as tar:
65+
self._validate_tar_header(tar)
66+
67+
for member in tar.getmembers():
68+
file_count += 1
69+
if file_count > self.max_files:
70+
raise UnsafeTarError(
71+
f"Too many files in archive (>{self.max_files})"
72+
)
73+
74+
self._validate_member(member, extract_path, overwrite)
75+
76+
if member.size > self.max_file_size:
77+
raise UnsafeTarError(
78+
f"File {member.name} exceeds size limit "
79+
f"({member.size} > {self.max_file_size})"
80+
)
81+
82+
target_path = self._get_safe_path(member, extract_path)
83+
84+
try:
85+
if member.isfile():
86+
self._extract_file(tar, member, target_path)
87+
elif member.isdir():
88+
target_path.mkdir(parents=True, exist_ok=True)
89+
elif member.issym() or member.islnk():
90+
self._extract_symlink(member, target_path)
91+
else:
92+
self._extract_other(member, target_path)
93+
94+
extracted_files.append(target_path)
95+
96+
if self.preserve_permissions:
97+
self._preserve_metadata(member, target_path)
98+
99+
except Exception as e:
100+
raise UnsafeTarError(f"Failed to extract {member.name}: {str(e)}")
101+
102+
except tarfile.TarError as e:
103+
raise UnsafeTarError(f"Invalid tar file: {str(e)}")
104+
105+
return extracted_files
106+
107+
def _validate_tar_header(self, tar: tarfile.TarFile) -> None:
108+
"""Validate tar file header."""
109+
try:
110+
if not tar.getmembers():
111+
warnings.warn(
112+
"Tar file appears to be empty",
113+
SecurityWarning,
114+
stacklevel=2
115+
)
116+
except (tarfile.TarError, EOFError) as e:
117+
raise UnsafeTarError(f"Invalid tar header: {str(e)}")
118+
119+
def _validate_member(
120+
self,
121+
member: tarfile.TarInfo,
122+
extract_path: Path,
123+
overwrite: bool
124+
) -> None:
125+
"""Validate a tar member for safety."""
126+
if not self.allow_absolute_paths and member.path.startswith('/'):
127+
raise UnsafeTarError(
128+
f"Absolute path not allowed: {member.name}"
129+
)
130+
131+
target_path = self._get_safe_path(member, extract_path)
132+
133+
# Check for path traversal
134+
try:
135+
target_abs = target_path.resolve()
136+
extract_abs = extract_path.resolve()
137+
if not str(target_abs).startswith(str(extract_abs)):
138+
raise UnsafeTarError(
139+
f"Path traversal detected: {member.name} -> {target_path}"
140+
)
141+
except (OSError, ValueError):
142+
target_str = str(target_path.absolute())
143+
extract_str = str(extract_path.absolute())
144+
if not target_str.startswith(extract_str):
145+
raise UnsafeTarError(
146+
f"Path traversal detected: {member.name} -> {target_path}"
147+
)
148+
149+
if target_path.exists() and not overwrite:
150+
raise UnsafeTarError(
151+
f"File already exists and overwrite=False: {target_path}"
152+
)
153+
154+
if member.type in self.blocked_types:
155+
raise UnsafeTarError(
156+
f"Blocked file type for {member.name}: {member.type}"
157+
)
158+
159+
def _get_safe_path(self, member: tarfile.TarInfo, extract_path: Path) -> Path:
160+
"""Get safe path for extraction."""
161+
path = os.path.normpath(member.name)
162+
if path.startswith('/'):
163+
path = path[1:] if not self.allow_absolute_paths else path
164+
return extract_path / path
165+
166+
def _extract_file(
167+
self,
168+
tar: tarfile.TarFile,
169+
member: tarfile.TarInfo,
170+
target_path: Path
171+
) -> None:
172+
"""Extract a regular file."""
173+
target_path.parent.mkdir(parents=True, exist_ok=True)
174+
175+
with tempfile.NamedTemporaryFile(
176+
dir=target_path.parent,
177+
prefix='.tmp_',
178+
delete=False
179+
) as tmp:
180+
try:
181+
src = tar.extractfile(member)
182+
if src is None:
183+
raise UnsafeTarError(f"Could not extract file: {member.name}")
184+
185+
bytes_copied = 0
186+
chunk_size = 8192
187+
while bytes_copied < member.size:
188+
chunk = src.read(min(chunk_size, member.size - bytes_copied))
189+
if not chunk:
190+
break
191+
bytes_copied += len(chunk)
192+
tmp.write(chunk)
193+
194+
src.close()
195+
tmp.close()
196+
197+
if target_path.exists():
198+
target_path.unlink()
199+
os.rename(tmp.name, target_path)
200+
201+
except Exception:
202+
if os.path.exists(tmp.name):
203+
os.unlink(tmp.name)
204+
raise
205+
206+
def _extract_symlink(self, member: tarfile.TarInfo, target_path: Path) -> None:
207+
"""Extract a symlink."""
208+
if member.linkpath:
209+
target = Path(member.linkpath)
210+
if target.is_absolute() and not self.allow_absolute_paths:
211+
raise UnsafeTarError(
212+
f"Absolute symlink target not allowed: {member.linkpath}"
213+
)
214+
215+
target_path.parent.mkdir(parents=True, exist_ok=True)
216+
if target_path.exists() or target_path.is_symlink():
217+
target_path.unlink()
218+
219+
target_path.symlink_to(member.linkpath)
220+
221+
def _extract_other(self, member: tarfile.TarInfo, target_path: Path) -> None:
222+
"""Extract other file types."""
223+
target_path.parent.mkdir(parents=True, exist_ok=True)
224+
target_path.touch()
225+
warnings.warn(
226+
f"Unsupported file type {member.type} for {member.name}",
227+
SecurityWarning,
228+
stacklevel=2
229+
)
230+
231+
def _preserve_metadata(self, member: tarfile.TarInfo, target_path: Path) -> None:
232+
"""Preserve file metadata."""
233+
try:
234+
if target_path.exists():
235+
if member.mtime:
236+
os.utime(target_path, times=(member.mtime, member.mtime))
237+
if member.mode:
238+
os.chmod(target_path, member.mode)
239+
except (OSError, PermissionError) as e:
240+
warnings.warn(
241+
f"Could not preserve metadata for {target_path}: {str(e)}",
242+
SecurityWarning,
243+
stacklevel=2
244+
)
245+
246+
247+
def safe_extract(
248+
tar_path: Union[str, Path],
249+
extract_path: Union[str, Path],
250+
**kwargs
251+
) -> List[Path]:
252+
"""Convenience function to safely extract a tar file."""
253+
extractor = SafeTarExtractor(**kwargs)
254+
return extractor.extract(tar_path, extract_path)

0 commit comments

Comments
 (0)