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
« 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."""
3import ipaddress
4from typing import Final
5from urllib.parse import unquote_to_bytes
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)
18def is_ambiguous_numeric_ipv4_host(hostname: str) -> bool:
19 """Return whether ``hostname`` is a valid noncanonical historical IPv4 form.
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
32 if not 1 <= len(components) <= len(_COMPONENT_MAXIMUMS):
33 return False
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 )
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(".")
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)
56def raw_authority_host(url: str) -> str | None:
57 """Return the host text of ``url``'s authority exactly as written.
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
82def has_percent_encoded_numeric_ipv4_authority(url: str) -> bool:
83 """Return whether ``url`` spells its host as percent-encoded IPv4.
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)
95def has_percent_escape_in_authority_host(url: str) -> bool:
96 """Return whether ``url``'s raw authority host contains any ``%``.
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
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
122 try:
123 return unquote_to_bytes(hostname).decode("ascii")
124 except UnicodeDecodeError:
125 return None
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
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:]
142 if not digits:
143 return None
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
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