Coverage for src/local_deep_research/security/network_utils.py: 100%
27 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Network utility functions for IP address classification.
3This module provides utilities for classifying IP addresses and hostnames
4as private/local vs public. Used for URL normalization and security validation.
5"""
7import ipaddress
9#: IPv6 transition ranges whose addresses stand for a host beyond a tunnel
10#: relay or translator, never for a peer on this host or LAN. CPython's
11#: ``is_private`` may nonetheless report addresses in 6to4 (``2002::/16``),
12#: Teredo (``2001::/32``, inside the private ``2001::/23`` block) and the
13#: RFC 8215 NAT64 local-use prefix (``64:ff9b:1::/48``) as private on some
14#: Python versions -- including ones wrapping a public IPv4 such as
15#: ``2002:808:808::1`` -- since which ranges ``ipaddress`` treats as
16#: private has changed between releases. The well-known NAT64 prefix
17#: (``64:ff9b::/96``) reads as public. LDR excludes all four ranges on
18#: every version, so the rule does not depend on either behaviour.
19#:
20#: The single definition shared by every PEER-classification decision (who
21#: is talking to this server). It deliberately does not feed
22#: ``is_private_ip``, whose outbound callers (URL normalisation, egress
23#: classification) answer a different question.
24IPV6_TRANSITION_NETWORKS = tuple(
25 ipaddress.ip_network(net)
26 for net in ("2002::/16", "2001::/32", "64:ff9b::/96", "64:ff9b:1::/48")
27)
30def is_ipv6_transition_address(
31 ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
32) -> bool:
33 """Whether ``ip`` lies in one of ``IPV6_TRANSITION_NETWORKS``."""
34 return ip.version == 6 and any(
35 ip in net for net in IPV6_TRANSITION_NETWORKS
36 )
39def is_private_peer_ip(host: str) -> bool:
40 """Whether a direct peer address is private/loopback/link-local.
42 ``is_private_ip`` for an inbound peer (``request.client.host``), used
43 where the answer confers trust -- e.g. whether that peer may supply
44 ``X-Forwarded-For``. It differs from ``is_private_ip`` only for IP
45 addresses:
47 * 6to4, Teredo and NAT64 addresses (``IPV6_TRANSITION_NETWORKS``) are
48 NOT private: such a peer is a remote host arriving through a relay
49 or translator, whatever ``ipaddress`` says.
50 * An IPv4-mapped IPv6 address (``::ffff:a.b.c.d``, how an IPv4 client
51 appears on a dual-stack listener) is judged by the IPv4 address it
52 wraps. CPython's ``ipaddress`` does the same from 3.12.4, but 3.12.0
53 to 3.12.3 answer from their IPv6 table, which lists
54 ``::ffff:0:0/96`` as private, so the unwrap here keeps this trust
55 decision from depending on the interpreter.
57 Non-IP strings fall through to ``is_private_ip`` unchanged.
58 """
59 candidate = host
60 if candidate.startswith("[") and candidate.endswith("]"):
61 candidate = candidate[1:-1]
62 try:
63 ip = ipaddress.ip_address(candidate)
64 except ValueError:
65 return is_private_ip(host)
66 if ip.version == 6 and ip.ipv4_mapped is not None:
67 ip = ip.ipv4_mapped
68 if is_ipv6_transition_address(ip):
69 return False
70 return ip.is_private or ip.is_loopback or ip.is_link_local
73def is_private_ip(hostname: str) -> bool:
74 """Check if hostname is a private/local IP address.
76 Recognizes:
77 - Localhost values (127.0.0.1, localhost, [::1], 0.0.0.0)
78 - RFC 1918 private IPv4 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
79 - IPv6 private addresses (fc00::/7, fe80::/10)
80 - mDNS .local domains
82 Args:
83 hostname: The hostname or IP address to check
85 Returns:
86 True if the hostname is a private/local address, False otherwise
88 Examples:
89 >>> is_private_ip("192.168.1.100")
90 True
91 >>> is_private_ip("172.16.0.50")
92 True
93 >>> is_private_ip("10.0.0.1")
94 True
95 >>> is_private_ip("8.8.8.8")
96 False
97 >>> is_private_ip("api.openai.com")
98 False
99 """
100 # Known localhost values
101 # Recognize a localhost spelling; no socket is bound here.
102 if hostname in ("localhost", "127.0.0.1", "[::1]", "0.0.0.0"): # nosec B104
103 return True
105 # Handle bracketed IPv6
106 if hostname.startswith("[") and hostname.endswith("]"):
107 hostname = hostname[1:-1]
109 try:
110 ip = ipaddress.ip_address(hostname)
111 # Check if private (includes 10.x, 172.16-31.x, 192.168.x, fc00::/7, etc.)
112 # Also check loopback and link-local
113 return ip.is_private or ip.is_loopback or ip.is_link_local
114 except ValueError:
115 # Not a valid IP address, check for .local domain (mDNS)
116 return hostname.endswith(".local")