mirror of
https://github.com/apache/superset.git
synced 2026-07-05 14:25:32 +00:00
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>
134 lines
4.8 KiB
Python
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
|