| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| from unittest.mock import patch |
| |
| import pytest |
| |
| from superset.utils.network import is_safe_host |
| |
| |
| @pytest.mark.parametrize( |
| ("resolved_ip", "expected"), |
| [ |
| # Public IPs → safe |
| ("93.184.216.34", True), # example.com |
| ("8.8.8.8", True), # Google DNS |
| ("2606:2800:220:1:248:1893:25c8:1946", True), # example.com IPv6 |
| # Loopback → unsafe |
| ("127.0.0.1", False), |
| ("::1", False), |
| # RFC-1918 private ranges → unsafe |
| ("10.0.0.1", False), |
| ("10.255.255.255", False), |
| ("172.16.0.1", False), |
| ("172.31.255.255", False), |
| ("192.168.0.1", False), |
| ("192.168.255.255", False), |
| # Link-local / IMDS → unsafe |
| ("169.254.169.254", False), # AWS/GCP/Azure metadata |
| ("169.254.0.1", False), |
| # 0.0.0.0/8 → unsafe |
| ("0.0.0.1", False), |
| # IPv4 multicast (224.0.0.0/4) → unsafe; ip.is_global returns True for |
| # multicast in Python, so the explicit blocklist entry is the only guard. |
| ("224.0.0.1", False), |
| ("239.255.255.255", False), |
| # IPv6 private → unsafe |
| ("fc00::1", False), |
| ("fe80::1", False), |
| # IPv6 multicast (ff00::/8) → unsafe |
| ("ff02::1", False), |
| ("ff0e::1", False), |
| ], |
| ) |
| def test_is_safe_host_ip_classification(resolved_ip: str, expected: bool) -> None: |
| """Hosts resolving to private/internal IPs must be rejected.""" |
| with patch( |
| "superset.utils.network.socket.getaddrinfo", |
| return_value=[(None, None, None, None, (resolved_ip, 0))], |
| ): |
| assert is_safe_host("any-hostname") is expected |
| |
| |
| def test_is_safe_host_unresolvable_returns_false() -> None: |
| """Unresolvable hostnames must return False (fail-closed).""" |
| import socket |
| |
| with patch( |
| "superset.utils.network.socket.getaddrinfo", |
| side_effect=socket.gaierror("Name or service not known"), |
| ): |
| assert is_safe_host("nonexistent.invalid") is False |
| |
| |
| def test_is_safe_host_empty_results_returns_false() -> None: |
| """An empty getaddrinfo result must return False (fail-closed).""" |
| with patch( |
| "superset.utils.network.socket.getaddrinfo", |
| return_value=[], |
| ): |
| assert is_safe_host("empty-result.example.com") is False |
| |
| |
| def test_is_safe_host_malformed_sockaddr_returns_false() -> None: |
| """A sockaddr that cannot be parsed as an IP address must return False.""" |
| with patch( |
| "superset.utils.network.socket.getaddrinfo", |
| return_value=[(None, None, None, None, ("not-an-ip", 0))], |
| ): |
| assert is_safe_host("malformed") is False |
| |
| |
| def test_is_safe_host_rejects_if_any_ip_is_private() -> None: |
| """A hostname that resolves to both a public and a private IP (split-DNS |
| or multi-homed host) must be rejected — all resolved IPs must be safe.""" |
| with patch( |
| "superset.utils.network.socket.getaddrinfo", |
| return_value=[ |
| (None, None, None, None, ("8.8.8.8", 0)), |
| (None, None, None, None, ("10.0.0.1", 0)), # private — must fail |
| ], |
| ): |
| assert is_safe_host("dual-homed.example.com") is False |
| |
| |
| @pytest.mark.parametrize( |
| "mapped_ip", |
| [ |
| "::ffff:127.0.0.1", # loopback via IPv4-mapped IPv6 |
| "::ffff:10.0.0.1", # RFC-1918 via IPv4-mapped IPv6 |
| "::ffff:169.254.169.254", # link-local via IPv4-mapped IPv6 |
| ], |
| ) |
| def test_is_safe_host_rejects_ipv4_mapped_ipv6(mapped_ip: str) -> None: |
| """IPv4-mapped IPv6 addresses must be unwrapped and checked against the |
| IPv4 unsafe networks — not treated as safe IPv6 addresses.""" |
| with patch( |
| "superset.utils.network.socket.getaddrinfo", |
| return_value=[(None, None, None, None, (mapped_ip, 0, 0, 0))], |
| ): |
| assert is_safe_host("mapped") is False |
| |
| |
| def test_is_safe_host_rejects_cgnat_range() -> None: |
| """100.64.0.0/10 (RFC 6598 shared address space) must be rejected.""" |
| with patch( |
| "superset.utils.network.socket.getaddrinfo", |
| return_value=[(None, None, None, None, ("100.100.100.200", 0))], |
| ): |
| assert is_safe_host("cgnat-host") is False |