|
16 | 16 | """Security tests for cache path traversal vulnerabilities.""" |
17 | 17 |
|
18 | 18 | import pathlib |
| 19 | +import socket |
19 | 20 | import sys |
20 | 21 |
|
21 | 22 | import pytest |
|
24 | 25 |
|
25 | 26 | from trestle.common.err import TrestleError |
26 | 27 | from trestle.core.remote.cache import HTTPSFetcher, SFTPFetcher |
27 | | -from trestle.core.remote.security import PathSecurityValidator |
| 28 | +from trestle.core.remote.security import PathSecurityValidator, URLSecurityValidator |
28 | 29 |
|
29 | 30 |
|
30 | 31 | class TestPathValidation: |
@@ -505,4 +506,206 @@ def test_attack_vector_sftp_private_network(self, tmp_path: pathlib.Path) -> Non |
505 | 506 | SFTPFetcher(tmp_path, evil_url) |
506 | 507 |
|
507 | 508 |
|
| 509 | +def test_https_fetcher_blocks_ssrf_aws_metadata(tmp_path: pathlib.Path) -> None: |
| 510 | + """Test that HTTPSFetcher blocks AWS metadata endpoint.""" |
| 511 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 512 | + with pytest.raises(TrestleError, match='cloud metadata endpoints'): |
| 513 | + HTTPSFetcher(tmp_path, 'https://169.254.169.254/latest/meta-data/') |
| 514 | + |
| 515 | + |
| 516 | +def test_https_fetcher_blocks_ssrf_gcp_metadata(tmp_path: pathlib.Path) -> None: |
| 517 | + """Test that HTTPSFetcher blocks GCP metadata endpoint.""" |
| 518 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 519 | + with pytest.raises(TrestleError, match='cloud metadata endpoints'): |
| 520 | + HTTPSFetcher(tmp_path, 'https://metadata.google.internal/computeMetadata/v1/') |
| 521 | + |
| 522 | + |
| 523 | +def test_https_fetcher_blocks_ssrf_localhost(tmp_path: pathlib.Path) -> None: |
| 524 | + """Test that HTTPSFetcher always blocks localhost (loopback).""" |
| 525 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 526 | + # Loopback is always blocked regardless of TRESTLE_BLOCK_PRIVATE_IPS |
| 527 | + with pytest.raises(TrestleError, match='127.0.0.0/8'): |
| 528 | + HTTPSFetcher(tmp_path, 'https://127.0.0.1:8080/') |
| 529 | + |
| 530 | + |
| 531 | +def test_https_fetcher_blocks_ssrf_ipv6_loopback(tmp_path: pathlib.Path) -> None: |
| 532 | + """Test that HTTPSFetcher always blocks IPv6 loopback.""" |
| 533 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 534 | + # IPv6 loopback is always blocked regardless of TRESTLE_BLOCK_PRIVATE_IPS |
| 535 | + with pytest.raises(TrestleError, match='::1/128'): |
| 536 | + HTTPSFetcher(tmp_path, 'https://[::1]:8080/') |
| 537 | + |
| 538 | + |
| 539 | +def test_https_fetcher_blocks_link_local_169_254(tmp_path: pathlib.Path) -> None: |
| 540 | + """Test that HTTPSFetcher always blocks link-local 169.254.x.x addresses.""" |
| 541 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 542 | + # Link-local is always blocked (includes metadata endpoints) |
| 543 | + with pytest.raises(TrestleError, match='169.254.0.0/16'): |
| 544 | + HTTPSFetcher(tmp_path, 'https://169.254.1.1/some/path') |
| 545 | + |
| 546 | + |
| 547 | +def test_https_fetcher_allows_private_network_10_by_default(tmp_path: pathlib.Path) -> None: |
| 548 | + """Test that HTTPSFetcher allows 10.x.x.x private network IPs by default.""" |
| 549 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 550 | + # RFC 1918 ranges are allowed by default to support private GitLab/internal OSCAL repos |
| 551 | + # This should not raise an error (though it will fail to connect in tests) |
| 552 | + try: |
| 553 | + fetcher = HTTPSFetcher(tmp_path, 'https://10.0.0.1:8500/v1/agent/self') |
| 554 | + # If we get here, the security validation passed (connection will fail but that's expected) |
| 555 | + assert fetcher is not None |
| 556 | + except TrestleError as e: |
| 557 | + # Should not be a security error about private IPs |
| 558 | + assert '10.0.0.0/8' not in str(e) or 'TRESTLE_BLOCK_PRIVATE_IPS' in str(e) |
| 559 | + |
| 560 | + |
| 561 | +def test_https_fetcher_blocks_private_network_10_when_configured(tmp_path: pathlib.Path, monkeypatch) -> None: |
| 562 | + """Test that HTTPSFetcher blocks 10.x.x.x when TRESTLE_BLOCK_PRIVATE_IPS is set.""" |
| 563 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 564 | + monkeypatch.setenv('TRESTLE_BLOCK_PRIVATE_IPS', 'true') |
| 565 | + with pytest.raises(TrestleError, match='10.0.0.0/8'): |
| 566 | + HTTPSFetcher(tmp_path, 'https://10.0.0.1:8500/v1/agent/self') |
| 567 | + |
| 568 | + |
| 569 | +def test_https_fetcher_allows_private_network_192_by_default(tmp_path: pathlib.Path) -> None: |
| 570 | + """Test that HTTPSFetcher allows 192.168.x.x private network IPs by default.""" |
| 571 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 572 | + try: |
| 573 | + fetcher = HTTPSFetcher(tmp_path, 'https://192.168.1.1/admin') |
| 574 | + assert fetcher is not None |
| 575 | + except TrestleError as e: |
| 576 | + assert '192.168.0.0/16' not in str(e) or 'TRESTLE_BLOCK_PRIVATE_IPS' in str(e) |
| 577 | + |
| 578 | + |
| 579 | +def test_https_fetcher_blocks_private_network_192_when_configured(tmp_path: pathlib.Path, monkeypatch) -> None: |
| 580 | + """Test that HTTPSFetcher blocks 192.168.x.x when TRESTLE_BLOCK_PRIVATE_IPS is set.""" |
| 581 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 582 | + monkeypatch.setenv('TRESTLE_BLOCK_PRIVATE_IPS', 'true') |
| 583 | + with pytest.raises(TrestleError, match='192.168.0.0/16'): |
| 584 | + HTTPSFetcher(tmp_path, 'https://192.168.1.1/admin') |
| 585 | + |
| 586 | + |
| 587 | +def test_https_fetcher_allows_private_network_172_by_default(tmp_path: pathlib.Path) -> None: |
| 588 | + """Test that HTTPSFetcher allows 172.16-31.x.x private network IPs by default.""" |
| 589 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 590 | + try: |
| 591 | + fetcher = HTTPSFetcher(tmp_path, 'https://172.16.0.1/admin') |
| 592 | + assert fetcher is not None |
| 593 | + except TrestleError as e: |
| 594 | + assert '172.16.0.0/12' not in str(e) or 'TRESTLE_BLOCK_PRIVATE_IPS' in str(e) |
| 595 | + |
| 596 | + |
| 597 | +def test_https_fetcher_blocks_private_network_172_when_configured(tmp_path: pathlib.Path, monkeypatch) -> None: |
| 598 | + """Test that HTTPSFetcher blocks 172.16-31.x.x when TRESTLE_BLOCK_PRIVATE_IPS is set.""" |
| 599 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 600 | + monkeypatch.setenv('TRESTLE_BLOCK_PRIVATE_IPS', 'true') |
| 601 | + with pytest.raises(TrestleError, match='172.16.0.0/12'): |
| 602 | + HTTPSFetcher(tmp_path, 'https://172.16.0.1/admin') |
| 603 | + |
| 604 | + |
| 605 | +def test_sftp_fetcher_blocks_ssrf_aws_metadata(tmp_path: pathlib.Path) -> None: |
| 606 | + """Test that SFTPFetcher blocks AWS metadata endpoint.""" |
| 607 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 608 | + with pytest.raises(TrestleError, match='cloud metadata endpoints'): |
| 609 | + SFTPFetcher(tmp_path, 'sftp://169.254.169.254/latest/meta-data/') |
| 610 | + |
| 611 | + |
| 612 | +def test_sftp_fetcher_blocks_ssrf_localhost(tmp_path: pathlib.Path) -> None: |
| 613 | + """Test that SFTPFetcher always blocks localhost (loopback).""" |
| 614 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 615 | + # Loopback is always blocked regardless of TRESTLE_BLOCK_PRIVATE_IPS |
| 616 | + with pytest.raises(TrestleError, match='127.0.0.0/8'): |
| 617 | + SFTPFetcher(tmp_path, 'sftp://127.0.0.1:22/data/file.json') |
| 618 | + |
| 619 | + |
| 620 | +def test_sftp_fetcher_blocks_link_local_169_254(tmp_path: pathlib.Path) -> None: |
| 621 | + """Test that SFTPFetcher always blocks link-local 169.254.x.x addresses.""" |
| 622 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 623 | + # Link-local is always blocked (includes metadata endpoints) |
| 624 | + with pytest.raises(TrestleError, match='169.254.0.0/16'): |
| 625 | + SFTPFetcher(tmp_path, 'sftp://169.254.1.1:22/some/path') |
| 626 | + |
| 627 | + |
| 628 | +def test_https_fetcher_blocks_invalid_scheme_http(tmp_path: pathlib.Path) -> None: |
| 629 | + """Test that HTTPSFetcher blocks HTTP scheme (only HTTPS allowed).""" |
| 630 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 631 | + with pytest.raises(TrestleError, match='Only HTTPS or SFTP schemes are allowed for remote URLs'): |
| 632 | + HTTPSFetcher(tmp_path, 'http://example.com/data.json') |
| 633 | + |
| 634 | + |
| 635 | +def test_https_fetcher_blocks_invalid_scheme_ftp(tmp_path: pathlib.Path) -> None: |
| 636 | + """Test that HTTPSFetcher blocks FTP scheme.""" |
| 637 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 638 | + with pytest.raises(TrestleError, match='Only HTTPS or SFTP schemes are allowed for remote URLs'): |
| 639 | + HTTPSFetcher(tmp_path, 'ftp://example.com/data.json') |
| 640 | + |
| 641 | + |
| 642 | +def test_sftp_fetcher_blocks_invalid_scheme_http(tmp_path: pathlib.Path) -> None: |
| 643 | + """Test that SFTPFetcher blocks HTTP scheme (only SFTP allowed).""" |
| 644 | + test_utils.ensure_trestle_config_dir(tmp_path) |
| 645 | + with pytest.raises(TrestleError, match='Only HTTPS or SFTP schemes are allowed for remote URLs'): |
| 646 | + SFTPFetcher(tmp_path, 'http://example.com/data.json') |
| 647 | + |
| 648 | + |
| 649 | +def test_url_validator_blocks_invalid_scheme(tmp_path: pathlib.Path) -> None: |
| 650 | + """Test that URLSecurityValidator blocks invalid schemes.""" |
| 651 | + from trestle.core.remote.security import URLSecurityValidator |
| 652 | + |
| 653 | + validator = URLSecurityValidator() |
| 654 | + |
| 655 | + with pytest.raises(TrestleError, match='Only HTTPS or SFTP schemes are allowed for remote URLs'): |
| 656 | + validator.validate_url('http://example.com/data.json') |
| 657 | + |
| 658 | + with pytest.raises(TrestleError, match='Only HTTPS or SFTP schemes are allowed for remote URLs'): |
| 659 | + validator.validate_url('ftp://example.com/data.json') |
| 660 | + |
| 661 | + with pytest.raises(TrestleError, match='Only HTTPS or SFTP schemes are allowed for remote URLs'): |
| 662 | + validator.validate_url('gopher://example.com/data') |
| 663 | + |
| 664 | + |
| 665 | +def test_url_validator_handles_dns_resolution_failure(tmp_path: pathlib.Path, monkeypatch) -> None: |
| 666 | + """Test that URLSecurityValidator handles DNS resolution failures gracefully.""" |
| 667 | + from trestle.core.remote.security import URLSecurityValidator |
| 668 | + |
| 669 | + # Mock socket.getaddrinfo to return empty list (no IPs resolved) |
| 670 | + def mock_getaddrinfo(hostname, port): |
| 671 | + return [] # Empty list - no IPs resolved |
| 672 | + |
| 673 | + monkeypatch.setattr(socket, 'getaddrinfo', mock_getaddrinfo) |
| 674 | + |
| 675 | + validator = URLSecurityValidator() |
| 676 | + with pytest.raises(TrestleError, match='No IP addresses resolved for hostname'): |
| 677 | + validator.validate_url('https://nonexistent.example.com/data.json') |
| 678 | + |
| 679 | + |
| 680 | +def test_url_validator_with_allowed_domains() -> None: |
| 681 | + """Test URL validation with domain allowlist.""" |
| 682 | + # Test with allowed domain - should pass |
| 683 | + validator = URLSecurityValidator(allowed_domains={'example.com', 'test.com'}) |
| 684 | + # This will fail DNS resolution but that's OK - we're testing the domain check happens first |
| 685 | + try: |
| 686 | + validator.validate_url('https://example.com/path') |
| 687 | + except TrestleError as e: |
| 688 | + # Should fail on DNS resolution, not domain check |
| 689 | + assert 'not in the allowed domains list' not in str(e) |
| 690 | + |
| 691 | + # Test with disallowed domain - should fail on domain check |
| 692 | + validator = URLSecurityValidator(allowed_domains={'example.com'}) |
| 693 | + with pytest.raises(TrestleError, match='not in the allowed domains list'): |
| 694 | + validator.validate_url('https://other.com/path') |
| 695 | + |
| 696 | + |
| 697 | +def test_url_validator_invalid_ip_address(monkeypatch) -> None: |
| 698 | + """Test handling of invalid IP address from getaddrinfo.""" |
| 699 | + |
| 700 | + def mock_getaddrinfo(hostname, port): |
| 701 | + # Return a malformed IP that will trigger ValueError in ipaddress.ip_address() |
| 702 | + return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('not-an-ip', 0))] |
| 703 | + |
| 704 | + monkeypatch.setattr(socket, 'getaddrinfo', mock_getaddrinfo) |
| 705 | + |
| 706 | + validator = URLSecurityValidator() |
| 707 | + with pytest.raises(TrestleError, match='Invalid IP address'): |
| 708 | + validator.validate_url('https://example.com/path') |
| 709 | + |
| 710 | + |
508 | 711 | # Made with Bob |
0 commit comments