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

1"""Network utility functions for IP address classification. 

2 

3This module provides utilities for classifying IP addresses and hostnames 

4as private/local vs public. Used for URL normalization and security validation. 

5""" 

6 

7import ipaddress 

8 

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) 

28 

29 

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 ) 

37 

38 

39def is_private_peer_ip(host: str) -> bool: 

40 """Whether a direct peer address is private/loopback/link-local. 

41 

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: 

46 

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. 

56 

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 

71 

72 

73def is_private_ip(hostname: str) -> bool: 

74 """Check if hostname is a private/local IP address. 

75 

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 

81 

82 Args: 

83 hostname: The hostname or IP address to check 

84 

85 Returns: 

86 True if the hostname is a private/local address, False otherwise 

87 

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 

104 

105 # Handle bracketed IPv6 

106 if hostname.startswith("[") and hostname.endswith("]"): 

107 hostname = hostname[1:-1] 

108 

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")