Files
superset2/tests/unit_tests/utils/test_network.py
Shaitan 5fb13f102a fix(network): validate target hostname in outbound requests (#39301)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Arpit Jain <3242828+arpitjain099@users.noreply.github.com>
Co-authored-by: Mafi <matt.fitzgerald@gmail.com>
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: sadpandajoe <jcli38@gmail.com>
Co-authored-by: JUST.in DO IT <justin.park@airbnb.com>
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
Co-authored-by: sha174n <pedro.sousa@preset.io>
2026-06-13 20:26:58 +01:00

134 lines
4.8 KiB
Python

# 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