Skip to content

Commit bb248eb

Browse files
authored
[PR aio-libs#12827/ccf218ab backport][3.15] Numeric ipv4 resolver bypass (aio-libs#12850)
1 parent b411319 commit bb248eb

5 files changed

Lines changed: 149 additions & 0 deletions

File tree

‎CHANGES/12827.bugfix.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Changed :class:`~aiohttp.TCPConnector` to reject legacy non-canonical numeric IPv4 host forms such as ``2130706433``, ``017700000001`` and ``127.1`` with :exc:`~aiohttp.InvalidUrlClientError`; only canonical dotted-quad IPv4 literals are now treated as IP address literals, while every other host is sent through the configured resolver -- by :user:`bdraco`.

‎aiohttp/connector.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ClientConnectorSSLError,
2828
ClientHttpProxyError,
2929
ClientProxyConnectionError,
30+
InvalidUrlClientError,
3031
ServerFingerprintMismatch,
3132
UnixClientConnectorError,
3233
cert_errors,
@@ -37,6 +38,7 @@
3738
from .helpers import (
3839
_SENTINEL,
3940
ceil_timeout,
41+
is_canonical_ipv4_address,
4042
is_ip_address,
4143
noop,
4244
sentinel,
@@ -1092,6 +1094,11 @@ async def _resolve_host(
10921094
) -> list[ResolveResult]:
10931095
"""Resolve host and return list of addresses."""
10941096
if is_ip_address(host):
1097+
# Reject legacy numeric IPv4 forms (e.g. 2130706433, 127.1) that
1098+
# socket would map onto an address, slipping past a connector-level
1099+
# policy that only sees the raw host.
1100+
if ":" not in host and not is_canonical_ipv4_address(host):
1101+
raise InvalidUrlClientError(host, "is not a canonical IPv4 address")
10951102
return [
10961103
{
10971104
"hostname": host,

‎aiohttp/helpers.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,28 @@ def is_ip_address(host: str | None) -> bool:
511511
return ":" in host or host.replace(".", "").isdigit()
512512

513513

514+
def is_canonical_ipv4_address(host: str) -> bool:
515+
"""Check if host is a canonical dotted-quad IPv4 address.
516+
517+
Rejects the legacy numeric forms that ``socket`` still accepts and
518+
maps onto an address, e.g. ``2130706433``, ``017700000001``, ``127.1``.
519+
"""
520+
parts = host.split(".")
521+
if len(parts) != 4:
522+
return False
523+
for part in parts:
524+
# Each octet must be 1-3 ASCII digits; reject unicode digits
525+
# (which ``str.isdigit`` accepts but ``int`` may not), octal
526+
# leading zeros, and values above 255.
527+
if not (1 <= len(part) <= 3) or not part.isascii() or not part.isdigit():
528+
return False
529+
if part[0] == "0" and len(part) != 1:
530+
return False
531+
if int(part) > 255:
532+
return False
533+
return True
534+
535+
514536
_cached_current_datetime: int | None = None
515537
_cached_formatted_datetime = ""
516538

‎tests/test_connector.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from aiohttp import client, connector as connector_module, hdrs, web
2727
from aiohttp.abc import AbstractResolver
2828
from aiohttp.client import ClientRequest, ClientTimeout
29+
from aiohttp.client_exceptions import InvalidUrlClientError
2930
from aiohttp.client_proto import ResponseHandler
3031
from aiohttp.client_reqrep import ConnectionKey
3132
from aiohttp.connector import (
@@ -1253,6 +1254,35 @@ async def test_tcp_connector_resolve_host(loop: asyncio.AbstractEventLoop) -> No
12531254
await conn.close()
12541255

12551256

1257+
async def test_tcp_connector_rejects_non_canonical_ipv4_alias() -> None:
1258+
"""Legacy numeric IPv4 aliases must not bypass the configured resolver."""
1259+
calls: list[str] = []
1260+
1261+
class _RecordingResolver(AbstractResolver):
1262+
async def resolve(
1263+
self,
1264+
host: str,
1265+
port: int = 0,
1266+
family: socket.AddressFamily = socket.AF_INET,
1267+
) -> list[ResolveResult]:
1268+
assert False
1269+
1270+
async def close(self) -> None:
1271+
"""Close the resolver."""
1272+
1273+
conn = aiohttp.TCPConnector(resolver=_RecordingResolver())
1274+
for alias in ("2130706433", "017700000001", "127.1"):
1275+
with pytest.raises(InvalidUrlClientError, match="canonical IPv4"):
1276+
await conn._resolve_host(alias, 8080)
1277+
1278+
# Resolver is never consulted, and a canonical IP still short-circuits it.
1279+
assert calls == []
1280+
res = await conn._resolve_host("127.0.0.1", 8080)
1281+
assert res[0]["host"] == "127.0.0.1"
1282+
assert calls == []
1283+
await conn.close()
1284+
1285+
12561286
@pytest.fixture
12571287
def dns_response(loop):
12581288
async def coro():

‎tests/test_helpers.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import base64
33
import datetime
44
import gc
5+
import ipaddress
6+
import itertools
57
import sys
68
import warnings
79
import weakref
@@ -319,6 +321,93 @@ def test_is_ip_address_invalid_type() -> None:
319321
helpers.is_ip_address(object())
320322

321323

324+
# ------------------------------- is_canonical_ipv4_address() ---------------
325+
326+
327+
@pytest.mark.parametrize(
328+
"host",
329+
[
330+
"0.0.0.0",
331+
"127.0.0.1",
332+
"8.8.8.8",
333+
"192.168.0.1",
334+
"255.255.255.255",
335+
],
336+
)
337+
def test_is_canonical_ipv4_address_accepts_dotted_quad(host: str) -> None:
338+
assert helpers.is_canonical_ipv4_address(host)
339+
340+
341+
@pytest.mark.parametrize(
342+
"host",
343+
[
344+
"2130706433", # decimal integer form of 127.0.0.1
345+
"017700000001", # octal form of 127.0.0.1
346+
"127.1", # short-hand form of 127.0.0.1
347+
"127.0.1", # 3-part short-hand
348+
"0177.0.0.1", # octal leading-zero octet
349+
"01.2.3.4", # octal leading-zero octet
350+
"256.0.0.1", # octet out of range
351+
"999.0.0.1", # octet out of range
352+
"1.2.3.4.5", # too many octets
353+
"127.0.0.", # trailing dot / empty octet
354+
"12³.0.0.1", # superscript digit (str.isdigit but not int)
355+
"127.0.0.1", # full-width digits
356+
"0xa.0.0.0", # hex octet
357+
" 127.0.0.1", # leading whitespace
358+
"127.0.0.1 ", # trailing whitespace
359+
"example.com", # domain name
360+
"", # empty
361+
],
362+
)
363+
def test_is_canonical_ipv4_address_rejects_non_canonical(host: str) -> None:
364+
assert not helpers.is_canonical_ipv4_address(host)
365+
366+
367+
def _ipaddress_accepts_ipv4(host: str) -> bool:
368+
"""Oracle: does the stdlib accept ``host`` as a canonical IPv4 address?"""
369+
try:
370+
ipaddress.IPv4Address(host)
371+
except ipaddress.AddressValueError:
372+
return False
373+
return True
374+
375+
376+
def test_is_canonical_ipv4_address_matches_stdlib() -> None:
377+
"""Prove equivalence with ``ipaddress.IPv4Address`` over a broad corpus.
378+
379+
The helper is a fast hand-rolled substitute for the stdlib parser; this
380+
exhaustively cross-checks the two agree on every combination of a set of
381+
octet-like tokens covering the known edge cases (leading zeros, out of
382+
range, empty, unicode digits, wrong octet count).
383+
"""
384+
tokens = [
385+
"0",
386+
"1",
387+
"9",
388+
"10",
389+
"99",
390+
"255",
391+
"256",
392+
"999",
393+
"00",
394+
"01",
395+
"0177",
396+
"1234",
397+
"",
398+
"a",
399+
"0x1",
400+
"1", # full-width 1
401+
"1²", # trailing superscript
402+
]
403+
for count in range(1, 5):
404+
for parts in itertools.product(tokens, repeat=count):
405+
host = ".".join(parts)
406+
assert helpers.is_canonical_ipv4_address(host) == _ipaddress_accepts_ipv4(
407+
host
408+
), host
409+
410+
322411
# ----------------------------------- TimeoutHandle -------------------
323412

324413

0 commit comments

Comments
 (0)