From b5ed232e96b34cee45c82d49422ab61111aabdbb Mon Sep 17 00:00:00 2001 From: A5rocks Date: Tue, 27 Oct 2026 02:09:52 -0400 Subject: [PATCH] Encode hostnames consistently --- docs/source/reference-io.rst | 11 +++++++++++ newsfragments/11.bugfix.rst | 1 + src/trio/_socket.py | 36 ++++++++++++++++++++++-------------- src/trio/_ssl.py | 7 +++++++ src/trio/_tests/test_ssl.py | 10 ++++++++++ src/trio/lowlevel.py | 1 + 6 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 newsfragments/11.bugfix.rst diff --git a/docs/source/reference-io.rst b/docs/source/reference-io.rst index d1eb083b08..202c8a9f1d 100644 --- a/docs/source/reference-io.rst +++ b/docs/source/reference-io.rst @@ -3,6 +3,17 @@ I/O in Trio =========== +Knowing what hostname you are actually connected to +--------------------------------------------------- + +If you pass a string hostname to Trio, for example in +`trio.socket.getaddrinfo` or `trio.SSLStream`, Trio will need to get a +bytes version of it at some point. To do this conversion yourself, we +recommend `trio.lowlevel.encode_hostname`. Notably, this differs from +what the Python standard library typically encodes hosts as. + +.. autofunction:: trio.lowlevel.encode_hostname + .. _abstract-stream-api: The abstract Stream API diff --git a/newsfragments/11.bugfix.rst b/newsfragments/11.bugfix.rst new file mode 100644 index 0000000000..7e98f19146 --- /dev/null +++ b/newsfragments/11.bugfix.rst @@ -0,0 +1 @@ +Ensure that we consistently encode all hostnames using IDNA 2008. diff --git a/src/trio/_socket.py b/src/trio/_socket.py index 504874553a..4c8c965c9e 100644 --- a/src/trio/_socket.py +++ b/src/trio/_socket.py @@ -90,6 +90,27 @@ async def __aexit__( return False +def encode_hostname(hostname: str) -> bytes: + """Encode a hostname using the same IDNA 2008 encoding Trio uses. + + If you want to know which exact hostname Trio will connect to, use this + function. + """ + # Given a unicode hostname, we want to do our own encoding using the idna + # module, rather than letting Python do it. (Python will use the old IDNA + # 2003 standard, and possibly get the wrong answer - see bpo-17305). + # However, the idna module is picky, and will refuse to process some valid + # hostname strings, like "::1". So if it's already ascii, we pass it + # through; otherwise, we encode it via IDNA. + try: + return hostname.encode("ascii") + except UnicodeEncodeError: + # UTS-46 defines various normalizations; in particular, by default + # idna.encode will error out if the hostname has Capital Letters + # in it; with uts46=True it will lowercase them instead. + return _idna.encode(hostname, uts46=True) + + ################################################################ # Overrides ################################################################ @@ -224,21 +245,8 @@ def numeric_only_failure(exc: BaseException) -> bool: flags | _NUMERIC_ONLY, ) # That failed; it's a real hostname. We better use a thread. - # - # Also, it might be a unicode hostname, in which case we want to do our - # own encoding using the idna module, rather than letting Python do - # it. (Python will use the old IDNA 2003 standard, and possibly get the - # wrong answer - see bpo-17305). However, the idna module is picky, and - # will refuse to process some valid hostname strings, like "::1". So if - # it's already ascii, we pass it through; otherwise, we encode it to. if isinstance(host, str): - try: - host = host.encode("ascii") - except UnicodeEncodeError: - # UTS-46 defines various normalizations; in particular, by default - # idna.encode will error out if the hostname has Capital Letters - # in it; with uts46=True it will lowercase them instead. - host = _idna.encode(host, uts46=True) + host = encode_hostname(host) hr = _resolver.get(None) if hr is not None: return await hr.getaddrinfo(host, port, family, type, proto, flags) diff --git a/src/trio/_ssl.py b/src/trio/_ssl.py index 52c5137ea1..7579f3445e 100644 --- a/src/trio/_ssl.py +++ b/src/trio/_ssl.py @@ -10,6 +10,7 @@ from . import _sync from ._highlevel_generic import aclose_forcefully +from ._socket import encode_hostname from ._util import ConflictDetector, final from .abc import Listener, Stream @@ -362,6 +363,12 @@ def __init__( server_side: bool = False, https_compatible: bool = False, ) -> None: + server_hostname = ( + encode_hostname(server_hostname) + if isinstance(server_hostname, str) + else server_hostname + ) + self.transport_stream: T_Stream = transport_stream self._state = _State.OK self._https_compatible = https_compatible diff --git a/src/trio/_tests/test_ssl.py b/src/trio/_tests/test_ssl.py index bb61ac563b..a9a4298efc 100644 --- a/src/trio/_tests/test_ssl.py +++ b/src/trio/_tests/test_ssl.py @@ -1378,3 +1378,13 @@ async def setup( await aclose_forcefully(ssl_listener) await aclose_forcefully(ssl_client) await aclose_forcefully(ssl_server) + + +async def test_server_hostname_is_idna_2008(client_ctx: SSLContext) -> None: + async with ssl_echo_server_raw(expect_fail=True) as sock: + good_ctx = client_ctx + s = SSLStream(sock, good_ctx, server_hostname="faß.de") + + assert s.server_hostname.encode("ascii") == trio.lowlevel.encode_hostname( + "faß.de" + ) diff --git a/src/trio/lowlevel.py b/src/trio/lowlevel.py index b6621d47ed..b3b66ed7df 100644 --- a/src/trio/lowlevel.py +++ b/src/trio/lowlevel.py @@ -52,6 +52,7 @@ wait_task_rescheduled as wait_task_rescheduled, wait_writable as wait_writable, ) +from ._socket import encode_hostname as encode_hostname from ._subprocess import open_process as open_process # This is the union of a subset of trio/_core/ and some things from trio/*.py.