|
16 | 16 | import os |
17 | 17 | import pathlib |
18 | 18 | import shutil |
| 19 | +from types import SimpleNamespace |
| 20 | + |
| 21 | +import pytest |
19 | 22 |
|
20 | 23 | from _pytest.monkeypatch import MonkeyPatch |
21 | 24 |
|
22 | 25 | from tests.test_utils import execute_command_and_assert, setup_for_ssp |
23 | 26 |
|
24 | | -from trestle.core.commands.author.jinja import _number_captions |
| 27 | +from trestle.common.err import TrestleError |
| 28 | +from trestle.core.commands.author.jinja import JinjaCmd, _number_captions |
25 | 29 | from trestle.core.commands.author.ssp import SSPGenerate |
26 | 30 | from trestle.core.markdown.docs_markdown_node import DocsMarkdownNode |
27 | 31 |
|
@@ -295,3 +299,109 @@ def test_jinja_with_template_only( |
295 | 299 | node1 = tree.get_node_for_key('# A') |
296 | 300 | node2 = tree.get_node_for_key('# C') |
297 | 301 | assert node1.subnodes[0].key == node2.subnodes[0].key |
| 302 | + |
| 303 | + |
| 304 | +def test_jinja_path_traversal_protection( |
| 305 | + testdata_dir: pathlib.Path, tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch |
| 306 | +) -> None: |
| 307 | + """Test that path traversal attacks are blocked in jinja command.""" |
| 308 | + from trestle.core.remote.security import PathSecurityValidator |
| 309 | + |
| 310 | + # Test path validation directly to ensure 100% coverage of the validation code |
| 311 | + # Test 1: Path traversal with ../ should fail |
| 312 | + with pytest.raises(TrestleError) as exc_info: |
| 313 | + output_file = tmp_trestle_dir / '../../../etc/passwd' |
| 314 | + PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) |
| 315 | + assert 'Security violation' in str(exc_info.value) |
| 316 | + assert 'Path traversal blocked' in str(exc_info.value) |
| 317 | + |
| 318 | + # Test 2: Path traversal with multiple ../ should fail |
| 319 | + with pytest.raises(TrestleError) as exc_info: |
| 320 | + output_file = tmp_trestle_dir / 'subdir/../../poc.txt' |
| 321 | + PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) |
| 322 | + assert 'Security violation' in str(exc_info.value) |
| 323 | + |
| 324 | + # Test 3: Absolute path should fail |
| 325 | + with pytest.raises(TrestleError) as exc_info: |
| 326 | + output_file = pathlib.Path('/tmp/attack.md') |
| 327 | + PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) |
| 328 | + assert 'Security violation' in str(exc_info.value) |
| 329 | + |
| 330 | + # Test 4: Complex traversal should fail |
| 331 | + with pytest.raises(TrestleError) as exc_info: |
| 332 | + output_file = tmp_trestle_dir / 'a/b/c/../../../../etc/passwd' |
| 333 | + PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) |
| 334 | + assert 'Security violation' in str(exc_info.value) |
| 335 | + |
| 336 | + # Test 5: Valid relative path should succeed |
| 337 | + output_file = tmp_trestle_dir / 'output/valid.md' |
| 338 | + PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) # Should not raise |
| 339 | + |
| 340 | + |
| 341 | +def test_jinja_docs_profile_path_traversal_protection(tmp_trestle_dir: pathlib.Path) -> None: |
| 342 | + """Test that path traversal attacks are blocked in jinja docs-profile mode.""" |
| 343 | + from trestle.core.remote.security import PathSecurityValidator |
| 344 | + |
| 345 | + # Test validation for multi-file output paths |
| 346 | + # Test 1: Path traversal in output directory should fail |
| 347 | + with pytest.raises(TrestleError) as exc_info: |
| 348 | + output_file = tmp_trestle_dir / '../../../etc/ac-1.md' |
| 349 | + PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) |
| 350 | + assert 'Security violation' in str(exc_info.value) |
| 351 | + assert 'Path traversal blocked' in str(exc_info.value) |
| 352 | + |
| 353 | + # Test 2: Complex path traversal should fail |
| 354 | + with pytest.raises(TrestleError) as exc_info: |
| 355 | + output_file = tmp_trestle_dir / 'controls/../../tmp/ac-1.md' |
| 356 | + PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) |
| 357 | + assert 'Security violation' in str(exc_info.value) |
| 358 | + |
| 359 | + # Test 3: Directory creation path traversal should fail |
| 360 | + with pytest.raises(TrestleError) as exc_info: |
| 361 | + group_dir = tmp_trestle_dir / '../../../etc/malicious' |
| 362 | + PathSecurityValidator.validate_local_path(group_dir, tmp_trestle_dir) |
| 363 | + assert 'Security violation' in str(exc_info.value) |
| 364 | + |
| 365 | + # Test 4: Valid relative path should succeed |
| 366 | + output_file = tmp_trestle_dir / 'controls_output/ac/ac-1.md' |
| 367 | + PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) # Should not raise |
| 368 | + |
| 369 | + |
| 370 | +def test_render_template_does_not_recursively_evaluate_untrusted_data(tmp_path: pathlib.Path) -> None: |
| 371 | + """Test that rendered attacker-controlled data is not re-evaluated as Jinja.""" |
| 372 | + template_path = tmp_path / 'template.j2' |
| 373 | + template_path.write_text('Title: {{ ssp.metadata.title }}', encoding='utf-8') |
| 374 | + |
| 375 | + jinja_env = JinjaCmd._create_jinja_environment(tmp_path) |
| 376 | + template = jinja_env.get_template(template_path.name) |
| 377 | + |
| 378 | + lut = { |
| 379 | + 'ssp': SimpleNamespace( |
| 380 | + metadata=SimpleNamespace(title="{{ namespace.__init__.__globals__.os.system('touch poc.txt') }}") |
| 381 | + ) |
| 382 | + } |
| 383 | + |
| 384 | + output = JinjaCmd.render_template(template, lut, tmp_path) |
| 385 | + |
| 386 | + assert output.startswith('Title: {{ namespace.__init__.__globals__.os.system(') |
| 387 | + assert 'touch poc.txt' in output |
| 388 | + assert '{{' in output |
| 389 | + assert '}}' in output |
| 390 | + assert '&' in output |
| 391 | + assert not (tmp_path / 'poc.txt').exists() |
| 392 | + |
| 393 | + |
| 394 | +def test_render_template_supports_trusted_include(tmp_path: pathlib.Path) -> None: |
| 395 | + """Test that trusted template includes continue to work.""" |
| 396 | + include_path = tmp_path / 'partial.j2' |
| 397 | + include_path.write_text('World', encoding='utf-8') |
| 398 | + |
| 399 | + template_path = tmp_path / 'template.j2' |
| 400 | + template_path.write_text("Hello {% include 'partial.j2' %}", encoding='utf-8') |
| 401 | + |
| 402 | + jinja_env = JinjaCmd._create_jinja_environment(tmp_path) |
| 403 | + template = jinja_env.get_template(template_path.name) |
| 404 | + |
| 405 | + output = JinjaCmd.render_template(template, {}, tmp_path) |
| 406 | + |
| 407 | + assert output == 'Hello World' |
0 commit comments