@@ -708,4 +708,209 @@ def mock_getaddrinfo(hostname, port):
708708 validator .validate_url ('https://example.com/path' )
709709
710710
711+ class TestSSRFBypassVulnerabilities :
712+ """Test fixes for SSRF bypass vulnerabilities (GHSA-h47f-gmjp-m7rr)."""
713+
714+ def test_blocks_ipv4_mapped_ipv6_cloud_metadata (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
715+ """Test that IPv4-mapped IPv6 cloud metadata addresses are blocked."""
716+ test_utils .ensure_trestle_config_dir (tmp_path )
717+
718+ # Mock DNS resolution to return IPv4-mapped IPv6 address
719+ def mock_getaddrinfo (hostname , port ):
720+ # Return ::ffff:169.254.169.254 (IPv4-mapped IPv6 for AWS metadata)
721+ return [(socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::ffff:169.254.169.254' , 443 , 0 , 0 ))]
722+
723+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
724+
725+ # Should block IPv4-mapped IPv6 cloud metadata address
726+ # The metadata endpoint check catches it first with "cloud metadata endpoints" message
727+ with pytest .raises (TrestleError , match = 'cloud metadata endpoints' ):
728+ HTTPSFetcher (tmp_path , 'https://[::ffff:169.254.169.254]/latest/meta-data/' )
729+
730+ def test_blocks_ipv4_mapped_ipv6_loopback (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
731+ """Test that IPv4-mapped IPv6 loopback addresses are blocked."""
732+ test_utils .ensure_trestle_config_dir (tmp_path )
733+
734+ # Mock DNS resolution to return IPv4-mapped IPv6 loopback
735+ def mock_getaddrinfo (hostname , port ):
736+ # Return ::ffff:127.0.0.1 (IPv4-mapped IPv6 for loopback)
737+ return [(socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::ffff:127.0.0.1' , 443 , 0 , 0 ))]
738+
739+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
740+
741+ # Should block IPv4-mapped IPv6 loopback
742+ with pytest .raises (TrestleError , match = '127.0.0.0/8' ):
743+ HTTPSFetcher (tmp_path , 'https://[::ffff:127.0.0.1]/admin' )
744+
745+ def test_blocks_ipv4_mapped_ipv6_rfc1918 (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
746+ """Test that IPv4-mapped IPv6 RFC 1918 addresses are blocked when configured."""
747+ test_utils .ensure_trestle_config_dir (tmp_path )
748+ monkeypatch .setenv ('TRESTLE_BLOCK_PRIVATE_IPS' , 'true' )
749+
750+ # Mock DNS resolution to return IPv4-mapped IPv6 private address
751+ def mock_getaddrinfo (hostname , port ):
752+ # Return ::ffff:10.0.0.1 (IPv4-mapped IPv6 for RFC 1918)
753+ return [(socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::ffff:10.0.0.1' , 443 , 0 , 0 ))]
754+
755+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
756+
757+ # Should block IPv4-mapped IPv6 private address when TRESTLE_BLOCK_PRIVATE_IPS is set
758+ with pytest .raises (TrestleError , match = '10.0.0.0/8' ):
759+ HTTPSFetcher (tmp_path , 'https://[::ffff:10.0.0.1]/admin' )
760+
761+ def test_blocks_zero_address_ipv4 (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
762+ """Test that 0.0.0.0 is blocked (reaches localhost on Linux)."""
763+ test_utils .ensure_trestle_config_dir (tmp_path )
764+
765+ # Mock DNS resolution to return 0.0.0.0
766+ def mock_getaddrinfo (hostname , port ):
767+ return [(socket .AF_INET , socket .SOCK_STREAM , 6 , '' , ('0.0.0.0' , 443 ))] # noqa: S104 - intentional test for blocked address
768+
769+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
770+
771+ # Should block 0.0.0.0
772+ with pytest .raises (TrestleError , match = '0.0.0.0/8' ):
773+ HTTPSFetcher (tmp_path , 'https://0.0.0.0/admin' )
774+
775+ def test_blocks_zero_address_ipv6 (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
776+ """Test that :: (IPv6 unspecified) is blocked."""
777+ test_utils .ensure_trestle_config_dir (tmp_path )
778+
779+ # Mock DNS resolution to return ::
780+ def mock_getaddrinfo (hostname , port ):
781+ return [(socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::' , 443 , 0 , 0 ))]
782+
783+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
784+
785+ # Should block ::
786+ with pytest .raises (TrestleError , match = '::/128' ):
787+ HTTPSFetcher (tmp_path , 'https://[::]/admin' )
788+
789+ def test_metadata_endpoint_canonicalization (self ) -> None :
790+ """Test that metadata endpoint check canonicalizes IPv4-mapped IPv6 addresses.
791+
792+ Note: This test directly calls a private method (_check_metadata_endpoints) to verify
793+ the canonicalization logic in isolation. While this creates coupling to implementation
794+ details, it's necessary to test this specific security-critical path without requiring
795+ full DNS resolution setup.
796+ """
797+ from trestle .core .remote .security import URLSecurityValidator
798+
799+ validator = URLSecurityValidator ()
800+
801+ # Test that bracketed IPv4-mapped IPv6 literal is canonicalized and blocked
802+ with pytest .raises (TrestleError , match = 'cloud metadata endpoints' ):
803+ # This should be canonicalized to 169.254.169.254 and blocked
804+ validator ._check_metadata_endpoints ('::ffff:169.254.169.254' )
805+
806+ def test_canonicalize_ip_method (self ) -> None :
807+ """Test the _canonicalize_ip method directly."""
808+ import ipaddress
809+ from trestle .core .remote .security import URLSecurityValidator
810+
811+ validator = URLSecurityValidator ()
812+
813+ # Test IPv4-mapped IPv6 canonicalization
814+ ipv6_mapped = ipaddress .ip_address ('::ffff:169.254.169.254' )
815+ canonical = validator ._canonicalize_ip (ipv6_mapped )
816+ assert isinstance (canonical , ipaddress .IPv4Address )
817+ assert str (canonical ) == '169.254.169.254'
818+
819+ # Test regular IPv6 is unchanged
820+ ipv6_regular = ipaddress .ip_address ('2001:db8::1' )
821+ canonical = validator ._canonicalize_ip (ipv6_regular )
822+ assert isinstance (canonical , ipaddress .IPv6Address )
823+ assert str (canonical ) == '2001:db8::1'
824+
825+ # Test IPv4 is unchanged
826+ ipv4 = ipaddress .ip_address ('192.0.2.1' )
827+ canonical = validator ._canonicalize_ip (ipv4 )
828+ assert isinstance (canonical , ipaddress .IPv4Address )
829+ assert str (canonical ) == '192.0.2.1'
830+
831+ def test_version_check_prevents_type_error (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
832+ """Test that version checking prevents TypeError when comparing IPv4 and IPv6."""
833+ test_utils .ensure_trestle_config_dir (tmp_path )
834+
835+ # Mock DNS to return both IPv4 and IPv6 addresses
836+ def mock_getaddrinfo (hostname , port ):
837+ return [
838+ (socket .AF_INET , socket .SOCK_STREAM , 6 , '' , ('127.0.0.1' , 443 )),
839+ (socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::1' , 443 , 0 , 0 )),
840+ ]
841+
842+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
843+
844+ # Should block both without TypeError
845+ with pytest .raises (TrestleError , match = '127.0.0.0/8|::1/128' ):
846+ HTTPSFetcher (tmp_path , 'https://localhost/admin' )
847+
848+ def test_sftp_blocks_ipv4_mapped_ipv6_cloud_metadata (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
849+ """Test that SFTPFetcher also blocks IPv4-mapped IPv6 cloud metadata."""
850+ test_utils .ensure_trestle_config_dir (tmp_path )
851+
852+ # Mock DNS resolution to return IPv4-mapped IPv6 address
853+ def mock_getaddrinfo (hostname , port ):
854+ return [(socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::ffff:169.254.169.254' , 22 , 0 , 0 ))]
855+
856+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
857+
858+ # Should block IPv4-mapped IPv6 cloud metadata address
859+ # The metadata endpoint check catches it first with "cloud metadata endpoints" message
860+ with pytest .raises (TrestleError , match = 'cloud metadata endpoints' ):
861+ SFTPFetcher (tmp_path , 'sftp://[::ffff:169.254.169.254]/data' )
862+
863+ def test_sftp_blocks_zero_address (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
864+ """Test that SFTPFetcher blocks 0.0.0.0."""
865+ test_utils .ensure_trestle_config_dir (tmp_path )
866+
867+ # Mock DNS resolution to return 0.0.0.0
868+ def mock_getaddrinfo (hostname , port ):
869+ return [(socket .AF_INET , socket .SOCK_STREAM , 6 , '' , ('0.0.0.0' , 22 ))] # noqa: S104 - intentional test for blocked address
870+
871+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
872+
873+ # Should block 0.0.0.0
874+ with pytest .raises (TrestleError , match = '0.0.0.0/8' ):
875+ SFTPFetcher (tmp_path , 'sftp://0.0.0.0/data' )
876+
877+ def test_sftp_blocks_ipv4_mapped_ipv6_rfc1918 (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
878+ """Test that SFTPFetcher blocks IPv4-mapped IPv6 RFC 1918 addresses when configured.
879+
880+ This mirrors test_blocks_ipv4_mapped_ipv6_rfc1918 for SFTP to ensure BYPASS-4
881+ from the advisory is covered for both HTTPSFetcher and SFTPFetcher.
882+ """
883+ test_utils .ensure_trestle_config_dir (tmp_path )
884+ monkeypatch .setenv ('TRESTLE_BLOCK_PRIVATE_IPS' , 'true' )
885+
886+ # Mock DNS resolution to return IPv4-mapped IPv6 private address
887+ def mock_getaddrinfo (hostname , port ):
888+ # Return ::ffff:10.0.0.1 (IPv4-mapped IPv6 for RFC 1918)
889+ return [(socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::ffff:10.0.0.1' , 22 , 0 , 0 ))]
890+
891+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
892+
893+ # Should block IPv4-mapped IPv6 private address when TRESTLE_BLOCK_PRIVATE_IPS is set
894+ with pytest .raises (TrestleError , match = '10.0.0.0/8' ):
895+ SFTPFetcher (tmp_path , 'sftp://[::ffff:10.0.0.1]/data' )
896+
897+ def test_multiple_ipv4_mapped_addresses (self , tmp_path : pathlib .Path , monkeypatch ) -> None :
898+ """Test handling of multiple IPv4-mapped IPv6 addresses in DNS response."""
899+ test_utils .ensure_trestle_config_dir (tmp_path )
900+
901+ # Mock DNS to return multiple IPv4-mapped IPv6 addresses
902+ def mock_getaddrinfo (hostname , port ):
903+ return [
904+ (socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::ffff:169.254.169.254' , 443 , 0 , 0 )),
905+ (socket .AF_INET6 , socket .SOCK_STREAM , 6 , '' , ('::ffff:127.0.0.1' , 443 , 0 , 0 )),
906+ ]
907+
908+ monkeypatch .setattr (socket , 'getaddrinfo' , mock_getaddrinfo )
909+
910+ # Should block on first blocked address (169.254.0.0/16)
911+ # Using | pattern for robustness - either network match indicates proper blocking
912+ with pytest .raises (TrestleError , match = '169.254.0.0/16|127.0.0.0/8' ):
913+ HTTPSFetcher (tmp_path , 'https://evil.example.com/data' )
914+
915+
711916# Made with Bob
0 commit comments