diff --git a/aikido_zen/helpers/normalize_hostname.py b/aikido_zen/helpers/normalize_hostname.py new file mode 100644 index 000000000..42e271c4d --- /dev/null +++ b/aikido_zen/helpers/normalize_hostname.py @@ -0,0 +1,14 @@ +def normalize_hostname(hostname): + if not hostname or not isinstance(hostname, str): + return hostname + + # Lowercase and strip trailing dot (DNS resolvers may return FQDNs like "example.com.") + result = hostname.lower().rstrip(".") + + try: + # Decode Punycode if the hostname starts with xn-- + if result.startswith("xn--"): + result = result.encode("ascii").decode("idna") + return result + except (UnicodeError, LookupError): + return result diff --git a/aikido_zen/sinks/socket/normalize_hostname.py b/aikido_zen/sinks/socket/normalize_hostname.py index afed0c467..b596ebe9d 100644 --- a/aikido_zen/sinks/socket/normalize_hostname.py +++ b/aikido_zen/sinks/socket/normalize_hostname.py @@ -1,14 +1,3 @@ -def normalize_hostname(hostname): - if not hostname or not isinstance(hostname, str): - return hostname +from aikido_zen.helpers.normalize_hostname import normalize_hostname - result = hostname - try: - # Check if hostname contains punycode (starts with xn--) - if hostname.startswith("xn--"): - result = hostname.encode("ascii").decode("idna") - - return result - except (UnicodeError, LookupError): - # If decoding fails, return original hostname - return hostname +__all__ = ["normalize_hostname"] diff --git a/aikido_zen/sinks/socket/normalize_hostname_test.py b/aikido_zen/sinks/socket/normalize_hostname_test.py index d6963faf5..2e4feddee 100644 --- a/aikido_zen/sinks/socket/normalize_hostname_test.py +++ b/aikido_zen/sinks/socket/normalize_hostname_test.py @@ -55,10 +55,10 @@ def test_normalize_hostname_punycode_subdomain(): assert result == "müller.example.com" -def test_normalize_hostname_mixed_case(): - """Test that case is preserved in non-punycode hostnames""" - assert normalize_hostname("Example.COM") == "Example.COM" - assert normalize_hostname("MixedCase.Example.com") == "MixedCase.Example.com" +def test_normalize_hostname_lowercases(): + """Test that hostnames are lowercased""" + assert normalize_hostname("Example.COM") == "example.com" + assert normalize_hostname("MixedCase.Example.com") == "mixedcase.example.com" def test_normalize_hostname_non_string_input(): @@ -94,7 +94,12 @@ def test_normalize_hostname_punycode_not_starting_with_xn(): def test_normalize_hostname_punycode_error_handling(): """Test error handling for malformed punycode""" - # This should return the original string if decoding fails result = normalize_hostname("xn--invalid-punycode") - # Should either return the original or a decoded version if valid assert isinstance(result, str) + + +def test_normalize_hostname_trailing_dot(): + """Test that trailing dots (FQDN form from DNS resolvers) are stripped""" + assert normalize_hostname("example.com.") == "example.com" + assert normalize_hostname("metadata.google.internal.") == "metadata.google.internal" + assert normalize_hostname("metadata.goog.") == "metadata.goog" diff --git a/aikido_zen/vulnerabilities/ssrf/imds.py b/aikido_zen/vulnerabilities/ssrf/imds.py index 10e1affec..ae30a0321 100644 --- a/aikido_zen/vulnerabilities/ssrf/imds.py +++ b/aikido_zen/vulnerabilities/ssrf/imds.py @@ -4,6 +4,7 @@ """ from aikido_zen.helpers.ip_matcher import IPMatcher +from aikido_zen.helpers.normalize_hostname import normalize_hostname imds_addresses = IPMatcher( [ @@ -29,7 +30,7 @@ def is_trusted_hostname(hostname): """ If the hostname is a trusted host (like metadata.goog), there was no spoofing of hostnames, so it's not an attack """ - return hostname in trusted_hosts + return normalize_hostname(hostname) in trusted_hosts def resolves_to_imds_ip(resolved_ip_addresses, hostname): diff --git a/aikido_zen/vulnerabilities/ssrf/imds_test.py b/aikido_zen/vulnerabilities/ssrf/imds_test.py index 63fb53f8d..47506e238 100644 --- a/aikido_zen/vulnerabilities/ssrf/imds_test.py +++ b/aikido_zen/vulnerabilities/ssrf/imds_test.py @@ -21,6 +21,9 @@ def test_is_imds_ip_address_ipv6_mapped(): def test_trusted_hostname_returns_none(): """Test that trusted hostnames always return None.""" assert resolves_to_imds_ip(["1.1.1.1"], "metadata.google.internal") is None + assert resolves_to_imds_ip(["169.254.169.254"], "metadata.google.internal.") is None + assert resolves_to_imds_ip(["169.254.169.254"], "metadata.goog.") is None + assert resolves_to_imds_ip(["169.254.169.254"], "METADATA.GOOGLE.INTERNAL") is None def test_aws_imds_ipv4_present_returns_ip():