Skip to content

Commit f9fb20d

Browse files
mbeijenOndrej Filipp3ck
committed
Add IPNetPattern for accurate CIDR proxy/mount matching
Setting no_proxy patterns matched by hostname string comparison. So 192.168.0.1 would not match all://192.168.0.0/16. Add IPNetPattern, backed by ipaddress.ip_network, and a build_url_pattern() factory that returns IPNetPattern for all:// patterns containing a slash and WildcardURLPattern otherwise. Introduce a Pattern protocol so Client and AsyncClient can hold either kind in their _mounts dict. URLPattern is kept as a backward-compatible alias for WildcardURLPattern. Together with pydantic#967 will close - pydantic#829 - pydantic#899 Ported from encode/httpx#3741. Co-Authored-By: Ondrej Filip <ondrej.filip@firma.seznam.cz> Co-Authored-By: Bill Peck <bpeck@redhat.com>
1 parent 82b9e2d commit f9fb20d

3 files changed

Lines changed: 128 additions & 26 deletions

File tree

src/httpx2/httpx2/_client.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
TimeoutTypes,
4848
)
4949
from ._urls import URL, QueryParams
50-
from ._utils import URLPattern, get_environment_proxies
50+
from ._utils import Pattern, build_url_pattern, get_environment_proxies
5151

5252
if typing.TYPE_CHECKING:
5353
import ssl # pragma: no cover
@@ -665,8 +665,8 @@ def __init__(
665665
limits=limits,
666666
transport=transport,
667667
)
668-
self._mounts: dict[URLPattern, BaseTransport | None] = {
669-
URLPattern(key): None
668+
self._mounts: dict[Pattern, BaseTransport | None] = {
669+
build_url_pattern(key): None
670670
if proxy is None
671671
else self._init_proxy_transport(
672672
proxy,
@@ -680,7 +680,7 @@ def __init__(
680680
for key, proxy in proxy_map.items()
681681
}
682682
if mounts is not None:
683-
self._mounts.update({URLPattern(key): transport for key, transport in mounts.items()})
683+
self._mounts.update({build_url_pattern(key): transport for key, transport in mounts.items()})
684684

685685
self._mounts = dict(sorted(self._mounts.items()))
686686

@@ -1368,8 +1368,8 @@ def __init__(
13681368
transport=transport,
13691369
)
13701370

1371-
self._mounts: dict[URLPattern, AsyncBaseTransport | None] = {
1372-
URLPattern(key): None
1371+
self._mounts: dict[Pattern, AsyncBaseTransport | None] = {
1372+
build_url_pattern(key): None
13731373
if proxy is None
13741374
else self._init_proxy_transport(
13751375
proxy,
@@ -1383,7 +1383,7 @@ def __init__(
13831383
for key, proxy in proxy_map.items()
13841384
}
13851385
if mounts is not None:
1386-
self._mounts.update({URLPattern(key): transport for key, transport in mounts.items()})
1386+
self._mounts.update({build_url_pattern(key): transport for key, transport in mounts.items()})
13871387
self._mounts = dict(sorted(self._mounts.items()))
13881388

13891389
def _init_transport(

src/httpx2/httpx2/_utils.py

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import re
66
import typing
7+
from abc import abstractmethod
78
from urllib.request import getproxies
89

910
from ._types import PrimitiveData
@@ -115,24 +116,41 @@ def peek_filelike_length(stream: typing.Any) -> int | None:
115116
return length
116117

117118

118-
class URLPattern:
119+
class Pattern(typing.Protocol):
120+
@abstractmethod
121+
def matches(self, other: URL) -> bool:
122+
"""this method should never be accessed"""
123+
124+
@property
125+
@abstractmethod
126+
def priority(self) -> tuple[int, int, int]:
127+
"""this property should never be accessed"""
128+
129+
def __lt__(self, other: Pattern) -> bool:
130+
"""this method should never be accessed"""
131+
132+
def __eq__(self, other: typing.Any) -> bool:
133+
"""this method should never be accessed"""
134+
135+
136+
class WildcardURLPattern(Pattern):
119137
"""
120138
A utility class currently used for making lookups against proxy keys...
121139
122140
# Wildcard matching...
123-
>>> pattern = URLPattern("all://")
141+
>>> pattern = WildcardURLPattern("all://")
124142
>>> pattern.matches(httpx2.URL("http://example.com"))
125143
True
126144
127145
# Witch scheme matching...
128-
>>> pattern = URLPattern("https://")
146+
>>> pattern = WildcardURLPattern("https://")
129147
>>> pattern.matches(httpx2.URL("https://example.com"))
130148
True
131149
>>> pattern.matches(httpx2.URL("http://example.com"))
132150
False
133151
134152
# With domain matching...
135-
>>> pattern = URLPattern("https://example.com")
153+
>>> pattern = WildcardURLPattern("https://example.com")
136154
>>> pattern.matches(httpx2.URL("https://example.com"))
137155
True
138156
>>> pattern.matches(httpx2.URL("http://example.com"))
@@ -141,7 +159,7 @@ class URLPattern:
141159
False
142160
143161
# Wildcard scheme, with domain matching...
144-
>>> pattern = URLPattern("all://example.com")
162+
>>> pattern = WildcardURLPattern("all://example.com")
145163
>>> pattern.matches(httpx2.URL("https://example.com"))
146164
True
147165
>>> pattern.matches(httpx2.URL("http://example.com"))
@@ -150,7 +168,7 @@ class URLPattern:
150168
False
151169
152170
# With port matching...
153-
>>> pattern = URLPattern("https://example.com:1234")
171+
>>> pattern = WildcardURLPattern("https://example.com:1234")
154172
>>> pattern.matches(httpx2.URL("https://example.com:1234"))
155173
True
156174
>>> pattern.matches(httpx2.URL("https://example.com"))
@@ -199,7 +217,7 @@ def matches(self, other: URL) -> bool:
199217
@property
200218
def priority(self) -> tuple[int, int, int]:
201219
"""
202-
The priority allows URLPattern instances to be sortable, so that
220+
The priority allows WildcardURLPattern instances to be sortable, so that
203221
we can match from most specific to least specific.
204222
"""
205223
# URLs with a port should take priority over URLs without a port.
@@ -213,11 +231,56 @@ def priority(self) -> tuple[int, int, int]:
213231
def __hash__(self) -> int:
214232
return hash(self.pattern)
215233

216-
def __lt__(self, other: URLPattern) -> bool:
234+
def __lt__(self, other: Pattern) -> bool:
235+
return self.priority < other.priority
236+
237+
def __eq__(self, other: typing.Any) -> bool:
238+
return isinstance(other, WildcardURLPattern) and self.pattern == other.pattern
239+
240+
241+
class IPNetPattern(Pattern):
242+
def __init__(self, ip_net: str) -> None:
243+
try:
244+
addr, range = ip_net.split("/", 1)
245+
if addr[0] == "[" and addr[-1] == "]":
246+
addr = addr[1:-1]
247+
ip_net = f"{addr}/{range}"
248+
except ValueError:
249+
pass # not a range
250+
self.net = ipaddress.ip_network(ip_net)
251+
252+
def matches(self, other: URL) -> bool:
253+
try:
254+
return ipaddress.ip_address(other.host) in self.net
255+
except ValueError:
256+
return False
257+
258+
@property
259+
def priority(self) -> tuple[int, int, int]:
260+
return -1, 0, 0 # higher priority than WildcardURLPatterns
261+
262+
def __hash__(self) -> int:
263+
return hash(self.net)
264+
265+
def __lt__(self, other: Pattern) -> bool:
217266
return self.priority < other.priority
218267

219268
def __eq__(self, other: typing.Any) -> bool:
220-
return isinstance(other, URLPattern) and self.pattern == other.pattern
269+
return isinstance(other, IPNetPattern) and self.net == other.net
270+
271+
272+
# Backward-compatible alias so existing code using URLPattern("...") keeps working.
273+
URLPattern = WildcardURLPattern
274+
275+
276+
def build_url_pattern(pattern: str) -> Pattern:
277+
try:
278+
proto, rest = pattern.split("://", 1)
279+
if proto == "all" and "/" in rest:
280+
return IPNetPattern(rest)
281+
except ValueError: # covers .split() and IPNetPattern
282+
pass
283+
return WildcardURLPattern(pattern)
221284

222285

223286
def is_ipv4_hostname(hostname: str) -> bool:

tests/httpx2/test_utils.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
import pytest
1010

1111
import httpx2
12-
from httpx2._utils import URLPattern, get_environment_proxies
12+
from httpx2._utils import (
13+
IPNetPattern,
14+
WildcardURLPattern,
15+
build_url_pattern,
16+
get_environment_proxies,
17+
)
1318

1419
if typing.TYPE_CHECKING:
1520
from conftest import TestServer
@@ -134,24 +139,58 @@ def test_get_environment_proxies(environment: dict[str, str], proxies: dict[str,
134139
("http://", "https://example.com", False),
135140
("all://", "https://example.com:123", True),
136141
("", "https://example.com:123", True),
142+
("all://192.168.0.0/24", "http://192.168.0.1", True),
143+
("all://192.168.0.0/24", "https://192.168.1.1", False),
144+
("all://[2001:db8:abcd:0012::]/64", "http://[2001:db8:abcd:12::1]", True),
145+
("all://[2001:db8:abcd:0012::]/64", "http://[2001:db8:abcd:13::1]:8080", False),
137146
],
138147
)
139148
def test_url_matches(pattern: str, url: str, expected: bool) -> None:
140-
url_pattern = URLPattern(pattern)
149+
url_pattern = build_url_pattern(pattern)
141150
assert url_pattern.matches(httpx2.URL(url)) == expected
142151

143152

153+
@pytest.mark.parametrize(
154+
["pattern", "url", "expected"],
155+
[
156+
("all://192.168.0.0/24", "http://192.168.0.1", True),
157+
("all://192.168.0.1", "http://192.168.0.1", True),
158+
("all://192.168.0.0/24", "foobar", False),
159+
],
160+
)
161+
def test_IPNetPattern(pattern: str, url: str, expected: bool) -> None:
162+
_, rest = pattern.split("://", 1)
163+
ip_pattern = IPNetPattern(rest)
164+
assert ip_pattern.matches(httpx2.URL(url)) == expected
165+
166+
167+
def test_build_url_pattern() -> None:
168+
pattern1 = build_url_pattern("all://192.168.0.0/16")
169+
pattern2 = build_url_pattern("all://192.168.0.0/16")
170+
pattern3 = build_url_pattern("all://192.168.0.1")
171+
assert isinstance(pattern1, IPNetPattern)
172+
assert isinstance(pattern2, IPNetPattern)
173+
assert isinstance(pattern3, WildcardURLPattern)
174+
assert pattern1 == pattern2
175+
assert pattern2 != pattern3
176+
assert pattern1 < pattern3
177+
assert hash(pattern1) == hash(pattern2)
178+
assert hash(pattern2) != hash(pattern3)
179+
180+
144181
def test_pattern_priority() -> None:
145182
matchers = [
146-
URLPattern("all://"),
147-
URLPattern("http://"),
148-
URLPattern("http://example.com"),
149-
URLPattern("http://example.com:123"),
183+
build_url_pattern("all://"),
184+
build_url_pattern("http://"),
185+
build_url_pattern("http://example.com"),
186+
build_url_pattern("http://example.com:123"),
187+
build_url_pattern("all://192.168.0.0/16"),
150188
]
151189
random.shuffle(matchers)
152190
assert sorted(matchers) == [
153-
URLPattern("http://example.com:123"),
154-
URLPattern("http://example.com"),
155-
URLPattern("http://"),
156-
URLPattern("all://"),
191+
build_url_pattern("all://192.168.0.0/16"),
192+
build_url_pattern("http://example.com:123"),
193+
build_url_pattern("http://example.com"),
194+
build_url_pattern("http://"),
195+
build_url_pattern("all://"),
157196
]

0 commit comments

Comments
 (0)