Skip to content

Commit d00a0c2

Browse files
authored
Merge commit from fork
Signed-off-by: degenaro <lou.degenaro@gmail.com>
1 parent 89f4e53 commit d00a0c2

3 files changed

Lines changed: 451 additions & 2 deletions

File tree

tests/trestle/core/remote/cache_security_test.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,265 @@ def mock_relative_to(self, other, *args, **kwargs):
102102
PathSecurityValidator.validate_cache_path(valid_path, cache_root)
103103

104104

105+
class TestTrestleURIPathValidation:
106+
"""Test trestle:// URI path validation."""
107+
108+
def test_validate_trestle_uri_path_normal(self) -> None:
109+
"""Test that normal trestle:// URI paths pass validation."""
110+
PathSecurityValidator.validate_trestle_uri_path('catalogs/nist/catalog.json')
111+
PathSecurityValidator.validate_trestle_uri_path('profiles/fedramp/profile.json')
112+
PathSecurityValidator.validate_trestle_uri_path('components/mycomp/component.json')
113+
114+
def test_validate_trestle_uri_path_blocks_traversal(self) -> None:
115+
"""Test that trestle:// URI paths with .. are blocked."""
116+
with pytest.raises(TrestleError, match='Security violation:.*[Pp]ath traversal blocked.*trestle://'):
117+
PathSecurityValidator.validate_trestle_uri_path('../../etc/passwd')
118+
119+
with pytest.raises(TrestleError, match='Security violation:.*[Pp]ath traversal blocked.*trestle://'):
120+
PathSecurityValidator.validate_trestle_uri_path('catalogs/../../../etc/shadow')
121+
122+
with pytest.raises(TrestleError, match='Security violation:.*[Pp]ath traversal blocked.*trestle://'):
123+
PathSecurityValidator.validate_trestle_uri_path('../sensitive/file.json')
124+
125+
126+
class TestLocalPathValidation:
127+
"""Test local path validation."""
128+
129+
def test_validate_local_path_within_workspace(self, tmp_path: pathlib.Path) -> None:
130+
"""Test that valid paths within trestle workspace are accepted."""
131+
trestle_root = tmp_path / 'trestle-workspace'
132+
trestle_root.mkdir(parents=True)
133+
134+
# Valid path within workspace
135+
valid_path = trestle_root / 'catalogs' / 'nist' / 'catalog.json'
136+
PathSecurityValidator.validate_local_path(valid_path, trestle_root) # Should not raise
137+
138+
def test_validate_local_path_traversal_blocked(self, tmp_path: pathlib.Path) -> None:
139+
"""Test that path traversal outside workspace is blocked."""
140+
trestle_root = tmp_path / 'trestle-workspace'
141+
trestle_root.mkdir(parents=True)
142+
143+
# Attempt to traverse outside workspace
144+
evil_path = trestle_root / '..' / '..' / 'etc' / 'passwd'
145+
146+
with pytest.raises(TrestleError, match='Security violation.*[Pp]ath traversal blocked'):
147+
PathSecurityValidator.validate_local_path(evil_path, trestle_root)
148+
149+
def test_validate_local_path_absolute_outside_blocked(self, tmp_path: pathlib.Path) -> None:
150+
"""Test that absolute paths outside workspace are blocked."""
151+
trestle_root = tmp_path / 'trestle-workspace'
152+
trestle_root.mkdir(parents=True)
153+
154+
# Absolute path outside workspace
155+
evil_path = pathlib.Path('/tmp/pwned.json')
156+
157+
with pytest.raises(TrestleError, match='Security violation.*[Pp]ath traversal blocked'):
158+
PathSecurityValidator.validate_local_path(evil_path, trestle_root)
159+
160+
def test_validate_local_path_unexpected_error(self, tmp_path: pathlib.Path, monkeypatch) -> None:
161+
"""Test that unexpected errors during validation are caught and wrapped."""
162+
trestle_root = tmp_path / 'trestle-workspace'
163+
trestle_root.mkdir(parents=True)
164+
165+
valid_path = trestle_root / 'catalogs' / 'file.json'
166+
167+
# Mock relative_to() to raise an unexpected exception (not ValueError)
168+
def mock_relative_to(self, other, *args, **kwargs):
169+
raise RuntimeError('Unexpected filesystem error')
170+
171+
monkeypatch.setattr(pathlib.Path, 'relative_to', mock_relative_to)
172+
173+
with pytest.raises(TrestleError, match='Error validating local path'):
174+
PathSecurityValidator.validate_local_path(valid_path, trestle_root)
175+
176+
177+
class TestLocalFilePathValidation:
178+
"""Test local file path validation with workspace boundaries and sensitive file checks."""
179+
180+
def test_validate_local_file_path_within_workspace(self, tmp_path: pathlib.Path) -> None:
181+
"""Test that files within workspace are allowed."""
182+
workspace = tmp_path / 'workspace'
183+
workspace.mkdir(parents=True)
184+
185+
file_path = workspace / 'catalogs' / 'catalog.json'
186+
PathSecurityValidator.validate_local_file_path(workspace, file_path, allow_outside_workspace=False)
187+
188+
def test_validate_local_file_path_outside_workspace_blocked(self, tmp_path: pathlib.Path) -> None:
189+
"""Test that files outside workspace are blocked when allow_outside_workspace=False."""
190+
workspace = tmp_path / 'workspace'
191+
workspace.mkdir(parents=True)
192+
193+
outside_file = tmp_path / 'outside.json'
194+
195+
with pytest.raises(TrestleError, match='Access to files outside the trestle workspace is not allowed'):
196+
PathSecurityValidator.validate_local_file_path(workspace, outside_file, allow_outside_workspace=False)
197+
198+
def test_validate_local_file_path_outside_workspace_allowed(self, tmp_path: pathlib.Path) -> None:
199+
"""Test that non-sensitive files outside workspace are allowed when allow_outside_workspace=True."""
200+
workspace = tmp_path / 'workspace'
201+
workspace.mkdir(parents=True)
202+
203+
# Create a safe file outside workspace
204+
outside_file = tmp_path / 'safe_file.json'
205+
outside_file.touch()
206+
207+
# Should not raise
208+
PathSecurityValidator.validate_local_file_path(workspace, outside_file, allow_outside_workspace=True)
209+
210+
def test_validate_local_file_path_blocks_etc_passwd(self, tmp_path: pathlib.Path) -> None:
211+
"""Test that /etc/passwd is blocked even with allow_outside_workspace=True."""
212+
workspace = tmp_path / 'workspace'
213+
workspace.mkdir(parents=True)
214+
215+
passwd_path = pathlib.Path('/etc/passwd')
216+
217+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
218+
PathSecurityValidator.validate_local_file_path(workspace, passwd_path, allow_outside_workspace=True)
219+
220+
def test_validate_local_file_path_blocks_etc_shadow(self, tmp_path: pathlib.Path) -> None:
221+
"""Test that /etc/shadow is blocked."""
222+
workspace = tmp_path / 'workspace'
223+
workspace.mkdir(parents=True)
224+
225+
shadow_path = pathlib.Path('/etc/shadow')
226+
227+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
228+
PathSecurityValidator.validate_local_file_path(workspace, shadow_path, allow_outside_workspace=True)
229+
230+
def test_validate_local_file_path_blocks_etc_group(self, tmp_path: pathlib.Path) -> None:
231+
"""Test that /etc/group is blocked."""
232+
workspace = tmp_path / 'workspace'
233+
workspace.mkdir(parents=True)
234+
235+
group_path = pathlib.Path('/etc/group')
236+
237+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
238+
PathSecurityValidator.validate_local_file_path(workspace, group_path, allow_outside_workspace=True)
239+
240+
def test_validate_local_file_path_blocks_etc_sudoers(self, tmp_path: pathlib.Path) -> None:
241+
"""Test that /etc/sudoers is blocked."""
242+
workspace = tmp_path / 'workspace'
243+
workspace.mkdir(parents=True)
244+
245+
sudoers_path = pathlib.Path('/etc/sudoers')
246+
247+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
248+
PathSecurityValidator.validate_local_file_path(workspace, sudoers_path, allow_outside_workspace=True)
249+
250+
def test_validate_local_file_path_blocks_ssh_directory(self, tmp_path: pathlib.Path) -> None:
251+
"""Test that .ssh directory is blocked."""
252+
workspace = tmp_path / 'workspace'
253+
workspace.mkdir(parents=True)
254+
255+
ssh_path = pathlib.Path('/home/user/.ssh/id_rsa')
256+
257+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
258+
PathSecurityValidator.validate_local_file_path(workspace, ssh_path, allow_outside_workspace=True)
259+
260+
def test_validate_local_file_path_blocks_aws_credentials(self, tmp_path: pathlib.Path) -> None:
261+
"""Test that .aws credentials are blocked."""
262+
workspace = tmp_path / 'workspace'
263+
workspace.mkdir(parents=True)
264+
265+
aws_path = pathlib.Path('/home/user/.aws/credentials')
266+
267+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
268+
PathSecurityValidator.validate_local_file_path(workspace, aws_path, allow_outside_workspace=True)
269+
270+
def test_validate_local_file_path_blocks_docker_config(self, tmp_path: pathlib.Path) -> None:
271+
"""Test that .docker config is blocked."""
272+
workspace = tmp_path / 'workspace'
273+
workspace.mkdir(parents=True)
274+
275+
docker_path = pathlib.Path('/home/user/.docker/config.json')
276+
277+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
278+
PathSecurityValidator.validate_local_file_path(workspace, docker_path, allow_outside_workspace=True)
279+
280+
def test_validate_local_file_path_blocks_kube_config(self, tmp_path: pathlib.Path) -> None:
281+
"""Test that .kube config is blocked."""
282+
workspace = tmp_path / 'workspace'
283+
workspace.mkdir(parents=True)
284+
285+
kube_path = pathlib.Path('/home/user/.kube/config')
286+
287+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
288+
PathSecurityValidator.validate_local_file_path(workspace, kube_path, allow_outside_workspace=True)
289+
290+
def test_validate_local_file_path_blocks_proc_environ(self, tmp_path: pathlib.Path) -> None:
291+
"""Test that /proc/self/environ is blocked."""
292+
workspace = tmp_path / 'workspace'
293+
workspace.mkdir(parents=True)
294+
295+
proc_path = pathlib.Path('/proc/self/environ')
296+
297+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
298+
PathSecurityValidator.validate_local_file_path(workspace, proc_path, allow_outside_workspace=True)
299+
300+
def test_validate_local_file_path_blocks_windows_system32(self, tmp_path: pathlib.Path) -> None:
301+
"""Test that Windows System32 is blocked."""
302+
workspace = tmp_path / 'workspace'
303+
workspace.mkdir(parents=True)
304+
305+
win_path = pathlib.Path('C:\\Windows\\System32\\config\\SAM')
306+
307+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
308+
PathSecurityValidator.validate_local_file_path(workspace, win_path, allow_outside_workspace=True)
309+
310+
def test_validate_local_file_path_blocks_windows_credentials(self, tmp_path: pathlib.Path) -> None:
311+
"""Test that Windows credentials are blocked."""
312+
workspace = tmp_path / 'workspace'
313+
workspace.mkdir(parents=True)
314+
315+
cred_path = pathlib.Path('C:\\Users\\user\\AppData\\Local\\Microsoft\\Credentials\\secret')
316+
317+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
318+
PathSecurityValidator.validate_local_file_path(workspace, cred_path, allow_outside_workspace=True)
319+
320+
def test_validate_local_file_path_blocks_var_log(self, tmp_path: pathlib.Path) -> None:
321+
"""Test that /var/log is blocked."""
322+
workspace = tmp_path / 'workspace'
323+
workspace.mkdir(parents=True)
324+
325+
log_path = pathlib.Path('/var/log/auth.log')
326+
327+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
328+
PathSecurityValidator.validate_local_file_path(workspace, log_path, allow_outside_workspace=True)
329+
330+
def test_validate_local_file_path_blocks_mysql_data(self, tmp_path: pathlib.Path) -> None:
331+
"""Test that MySQL data directory is blocked."""
332+
workspace = tmp_path / 'workspace'
333+
workspace.mkdir(parents=True)
334+
335+
mysql_path = pathlib.Path('/var/lib/mysql/users.MYD')
336+
337+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
338+
PathSecurityValidator.validate_local_file_path(workspace, mysql_path, allow_outside_workspace=True)
339+
340+
def test_validate_local_file_path_case_insensitive(self, tmp_path: pathlib.Path) -> None:
341+
"""Test that sensitive path checking is case-insensitive."""
342+
workspace = tmp_path / 'workspace'
343+
workspace.mkdir(parents=True)
344+
345+
# Test uppercase variations
346+
passwd_upper = pathlib.Path('/ETC/PASSWD')
347+
348+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
349+
PathSecurityValidator.validate_local_file_path(workspace, passwd_upper, allow_outside_workspace=True)
350+
351+
def test_validate_local_file_path_checks_original_and_resolved(self, tmp_path: pathlib.Path) -> None:
352+
"""Test that both original and resolved paths are checked for sensitive patterns."""
353+
workspace = tmp_path / 'workspace'
354+
workspace.mkdir(parents=True)
355+
356+
# Create a path that might resolve differently
357+
# The validator checks both the original string and resolved path
358+
sensitive_path = pathlib.Path('/home/user/.ssh/authorized_keys')
359+
360+
with pytest.raises(TrestleError, match='Attempt to access potentially sensitive system file'):
361+
PathSecurityValidator.validate_local_file_path(workspace, sensitive_path, allow_outside_workspace=True)
362+
363+
105364
class TestHTTPSFetcherPathTraversal:
106365
"""Test HTTPSFetcher protection against path traversal attacks."""
107366

trestle/core/remote/cache.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,29 @@ def __init__(self, trestle_root: pathlib.Path, uri: str) -> None:
166166
"""
167167
super().__init__(trestle_root, uri)
168168

169+
original_uri = uri
170+
is_file_uri = uri.startswith(const.FILE_URI)
171+
169172
# Handle as file:/// form
170-
if uri.startswith(const.FILE_URI):
173+
if is_file_uri:
171174
# strip off entire header including /
172175
uri = uri[len(const.FILE_URI) :]
173176

174177
# if it has a drive letter don't add / to front
175178
uri = uri if re.match(const.WINDOWS_DRIVE_LETTER_REGEX, uri) else '/' + uri
176179
elif uri.startswith(const.TRESTLE_HREF_HEADING):
177-
uri = str(trestle_root / uri[len(const.TRESTLE_HREF_HEADING) :])
180+
# Extract the path after 'trestle://'
181+
trestle_path = uri[len(const.TRESTLE_HREF_HEADING) :]
182+
183+
# Layer 1: Validate the trestle:// URI path for traversal sequences
184+
PathSecurityValidator.validate_trestle_uri_path(trestle_path)
185+
186+
uri = str(trestle_root / trestle_path)
178187
self._abs_path = pathlib.Path(uri).resolve()
188+
189+
# Layer 2: Validate resolved path stays within trestle workspace
190+
PathSecurityValidator.validate_local_path(self._abs_path, self._trestle_root)
191+
179192
self._cached_object_path = self._abs_path
180193
return
181194

@@ -196,6 +209,13 @@ def __init__(self, trestle_root: pathlib.Path, uri: str) -> None:
196209
except Exception:
197210
raise TrestleError(f'The uri provided is invalid or unresolvable as a file path: {uri}')
198211

212+
# Security validation for file:// URIs and relative paths
213+
# LocalFetcher is designed to access files outside workspace (e.g., test data, external catalogs)
214+
# Security is provided by blocking sensitive system files, not workspace boundaries
215+
# This prevents arbitrary file read vulnerabilities (PT-002) while allowing legitimate use
216+
logger.info(f'Validating local file access: {original_uri}')
217+
PathSecurityValidator.validate_local_file_path(self._trestle_root, self._abs_path, allow_outside_workspace=True)
218+
199219
# set the cached path to be the actual file path
200220
self._cached_object_path = self._abs_path
201221

0 commit comments

Comments
 (0)