Coverage for src/local_deep_research/security/legacy_ipv4.py: 100%

87 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Recognize ambiguous historical IPv4 host syntax without network access.""" 

2 

3import ipaddress 

4from typing import Final 

5from urllib.parse import unquote_to_bytes 

6 

7 

8AMBIGUOUS_NUMERIC_IPV4_HOST_ERROR: Final = "Ambiguous numeric IPv4 host" 

9ENCODED_NUMERIC_IPV4_HOST_ERROR: Final = "Encoded numeric IPv4 host" 

10_COMPONENT_MAXIMUMS: Final[tuple[tuple[int, ...], ...]] = ( 

11 (0xFFFFFFFF,), 

12 (0xFF, 0xFFFFFF), 

13 (0xFF, 0xFF, 0xFFFF), 

14 (0xFF, 0xFF, 0xFF, 0xFF), 

15) 

16 

17 

18def is_ambiguous_numeric_ipv4_host(hostname: str) -> bool: 

19 """Return whether ``hostname`` is a valid noncanonical historical IPv4 form. 

20 

21 This recognizes the one-to-four-part inet_aton-style grammar using decimal, 

22 leading-zero octal, and ``0x`` hexadecimal components. It deliberately does 

23 not perform DNS or platform parsing. 

24 """ 

25 try: 

26 _ = ipaddress.ip_address(hostname) 

27 except ValueError: 

28 components = hostname.split(".") 

29 else: 

30 return False 

31 

32 if not 1 <= len(components) <= len(_COMPONENT_MAXIMUMS): 

33 return False 

34 

35 maximums = _COMPONENT_MAXIMUMS[len(components) - 1] 

36 return all( 

37 _parse_component(component, maximum) is not None 

38 for component, maximum in zip(components, maximums, strict=True) 

39 ) 

40 

41 

42def is_percent_encoded_numeric_ipv4_host(hostname: str) -> bool: 

43 """Return whether one-pass percent decoding exposes an IPv4 address form.""" 

44 decoded_hostname = _decode_percent_escapes(hostname) 

45 if decoded_hostname is None or decoded_hostname == hostname: 

46 return False 

47 classification_hostname = decoded_hostname.rstrip(".") 

48 

49 try: 

50 parsed_ip = ipaddress.ip_address(classification_hostname) 

51 except ValueError: 

52 return is_ambiguous_numeric_ipv4_host(classification_hostname) 

53 return isinstance(parsed_ip, ipaddress.IPv4Address) 

54 

55 

56def raw_authority_host(url: str) -> str | None: 

57 """Return the host text of ``url``'s authority exactly as written. 

58 

59 The authority is the part between ``//`` and the first ``/``, ``?`` or 

60 ``#``; userinfo (up to the last ``@``) and a trailing ``:port`` are 

61 dropped and nothing is decoded or lowercased. Returns ``None`` when the 

62 URL has no ``//`` authority or the host is a bracketed IP literal. 

63 """ 

64 start = url.find("//") 

65 if start < 0: 

66 return None 

67 rest = url[start + 2 :] 

68 end = len(rest) 

69 for delimiter in "/?#": 

70 found = rest.find(delimiter) 

71 if found >= 0: 

72 end = min(end, found) 

73 authority = rest[:end].rpartition("@")[2] 

74 if authority.startswith("["): 

75 return None 

76 host, _, port = authority.rpartition(":") 

77 if not host or not (port == "" or port.isdigit()): 

78 host = authority 

79 return host 

80 

81 

82def has_percent_encoded_numeric_ipv4_authority(url: str) -> bool: 

83 """Return whether ``url`` spells its host as percent-encoded IPv4. 

84 

85 Deliberately works on the raw URL text, never on a parser's host: urllib3 

86 2.8 percent-decodes http(s) hosts (2.7 did not), so a parsed host can no 

87 longer show that the URL was encoded. Callers must refuse these URLs 

88 whatever the private-IP policy, since the encoded spelling is what the 

89 policy checks cannot see through. 

90 """ 

91 host = raw_authority_host(url) 

92 return host is not None and is_percent_encoded_numeric_ipv4_host(host) 

93 

94 

95def has_percent_escape_in_authority_host(url: str) -> bool: 

96 """Return whether ``url``'s raw authority host contains any ``%``. 

97 

98 urllib3 2.8 percent-decodes escapes of unreserved characters in http(s) 

99 hosts (``ev%69l.example`` parses as ``evil.example``), while other HTTP 

100 clients such as httpx send the escaped text to the resolver unchanged. 

101 A validator that checks the decoded host therefore checks a different 

102 name from the one those clients resolve. No real DNS name needs a 

103 percent escape, so callers refuse these hosts outright. Bracketed IP 

104 literals (whose IPv6 zone id is written ``%25``) are not covered. 

105 """ 

106 host = raw_authority_host(url) 

107 return host is not None and "%" in host 

108 

109 

110def _decode_percent_escapes(hostname: str) -> str | None: 

111 """Decode only well-formed ASCII percent escapes without recursive decoding.""" 

112 position = 0 

113 while (percent := hostname.find("%", position)) >= 0: 

114 if ( 

115 percent + 2 >= len(hostname) 

116 or _digit_value(hostname[percent + 1]) is None 

117 or _digit_value(hostname[percent + 2]) is None 

118 ): 

119 return None 

120 position = percent + 3 

121 

122 try: 

123 return unquote_to_bytes(hostname).decode("ascii") 

124 except UnicodeDecodeError: 

125 return None 

126 

127 

128def _parse_component(component: str, maximum: int) -> int | None: 

129 """Parse one historical IPv4 component without integer overflow.""" 

130 if not component: 

131 return None 

132 

133 base = 10 

134 digits = component 

135 if component.startswith(("0x", "0X")): 

136 base = 16 

137 digits = component[2:] 

138 elif len(component) > 1 and component.startswith("0"): 

139 base = 8 

140 digits = component[1:] 

141 

142 if not digits: 

143 return None 

144 

145 value = 0 

146 for character in digits: 

147 digit = _digit_value(character) 

148 if digit is None or digit >= base or value > (maximum - digit) // base: 

149 return None 

150 value = value * base + digit 

151 return value 

152 

153 

154def _digit_value(character: str) -> int | None: 

155 if "0" <= character <= "9": 

156 return ord(character) - ord("0") 

157 if "a" <= character <= "f": 

158 return ord(character) - ord("a") + 10 

159 if "A" <= character <= "F": 

160 return ord(character) - ord("A") + 10 

161 return None