diff --git a/src/httpcore2/httpcore2/_async/connection.py b/src/httpcore2/httpcore2/_async/connection.py index 8123afd3..4a7e92dd 100644 --- a/src/httpcore2/httpcore2/_async/connection.py +++ b/src/httpcore2/httpcore2/_async/connection.py @@ -107,7 +107,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream: try: if self._uds is None: kwargs = { - "host": self._origin.host.decode("ascii"), + "host": self._origin.normalized_host, "port": self._origin.port, "local_address": self._local_address, "timeout": timeout, @@ -133,7 +133,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream: kwargs = { "ssl_context": ssl_context, - "server_hostname": sni_hostname or self._origin.host.decode("ascii"), + "server_hostname": sni_hostname or self._origin.normalized_host, "timeout": timeout, } async with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/src/httpcore2/httpcore2/_async/http_proxy.py b/src/httpcore2/httpcore2/_async/http_proxy.py index 5cde1dd4..44821989 100644 --- a/src/httpcore2/httpcore2/_async/http_proxy.py +++ b/src/httpcore2/httpcore2/_async/http_proxy.py @@ -291,7 +291,7 @@ async def handle_async_request(self, request: Request) -> Response: kwargs = { "ssl_context": ssl_context, - "server_hostname": self._remote_origin.host.decode("ascii"), + "server_hostname": self._remote_origin.normalized_host, "timeout": timeout, } async with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/src/httpcore2/httpcore2/_async/socks_proxy.py b/src/httpcore2/httpcore2/_async/socks_proxy.py index 0c44ab33..b0c32de3 100644 --- a/src/httpcore2/httpcore2/_async/socks_proxy.py +++ b/src/httpcore2/httpcore2/_async/socks_proxy.py @@ -215,7 +215,7 @@ async def handle_async_request(self, request: Request) -> Response: try: # Connect to the proxy kwargs = { - "host": self._proxy_origin.host.decode("ascii"), + "host": self._proxy_origin.normalized_host, "port": self._proxy_origin.port, "timeout": timeout, } @@ -226,7 +226,7 @@ async def handle_async_request(self, request: Request) -> Response: # Connect to the remote host using socks5 kwargs = { "stream": stream, - "host": self._remote_origin.host.decode("ascii"), + "host": self._remote_origin.normalized_host, "port": self._remote_origin.port, "auth": self._proxy_auth, } @@ -242,7 +242,7 @@ async def handle_async_request(self, request: Request) -> Response: kwargs = { "ssl_context": ssl_context, - "server_hostname": sni_hostname or self._remote_origin.host.decode("ascii"), + "server_hostname": sni_hostname or self._remote_origin.normalized_host, "timeout": timeout, } async with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/src/httpcore2/httpcore2/_models.py b/src/httpcore2/httpcore2/_models.py index 0b397000..2d60d819 100644 --- a/src/httpcore2/httpcore2/_models.py +++ b/src/httpcore2/httpcore2/_models.py @@ -161,6 +161,17 @@ def __init__(self, scheme: bytes, host: bytes, port: int) -> None: self.host = host self.port = port + @property + def normalized_host(self) -> str: + """Hostname decoded to ASCII with any trailing FQDN dot removed. + + Use this wherever the hostname is passed to network operations (TLS + SNI, DNS resolution, socket connect) so that a trailing dot in an + FQDN like ``myhost.internal.`` does not cause certificate verification + failures. The raw ``host`` bytes are left untouched. + """ + return self.host.decode("ascii").removesuffix(".") + def __eq__(self, other: typing.Any) -> bool: return ( isinstance(other, Origin) @@ -171,9 +182,8 @@ def __eq__(self, other: typing.Any) -> bool: def __str__(self) -> str: scheme = self.scheme.decode("ascii") - host = self.host.decode("ascii") port = str(self.port) - return f"{scheme}://{host}:{port}" + return f"{scheme}://{self.normalized_host}:{port}" class URL: diff --git a/src/httpcore2/httpcore2/_sync/connection.py b/src/httpcore2/httpcore2/_sync/connection.py index 14ef8b8b..d6360dd4 100644 --- a/src/httpcore2/httpcore2/_sync/connection.py +++ b/src/httpcore2/httpcore2/_sync/connection.py @@ -107,7 +107,7 @@ def _connect(self, request: Request) -> NetworkStream: try: if self._uds is None: kwargs = { - "host": self._origin.host.decode("ascii"), + "host": self._origin.normalized_host, "port": self._origin.port, "local_address": self._local_address, "timeout": timeout, @@ -133,7 +133,7 @@ def _connect(self, request: Request) -> NetworkStream: kwargs = { "ssl_context": ssl_context, - "server_hostname": sni_hostname or self._origin.host.decode("ascii"), + "server_hostname": sni_hostname or self._origin.normalized_host, "timeout": timeout, } with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/src/httpcore2/httpcore2/_sync/http_proxy.py b/src/httpcore2/httpcore2/_sync/http_proxy.py index c0129f1a..3e7390ae 100644 --- a/src/httpcore2/httpcore2/_sync/http_proxy.py +++ b/src/httpcore2/httpcore2/_sync/http_proxy.py @@ -291,7 +291,7 @@ def handle_request(self, request: Request) -> Response: kwargs = { "ssl_context": ssl_context, - "server_hostname": self._remote_origin.host.decode("ascii"), + "server_hostname": self._remote_origin.normalized_host, "timeout": timeout, } with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/src/httpcore2/httpcore2/_sync/socks_proxy.py b/src/httpcore2/httpcore2/_sync/socks_proxy.py index c7744411..ab92a67c 100644 --- a/src/httpcore2/httpcore2/_sync/socks_proxy.py +++ b/src/httpcore2/httpcore2/_sync/socks_proxy.py @@ -215,7 +215,7 @@ def handle_request(self, request: Request) -> Response: try: # Connect to the proxy kwargs = { - "host": self._proxy_origin.host.decode("ascii"), + "host": self._proxy_origin.normalized_host, "port": self._proxy_origin.port, "timeout": timeout, } @@ -226,7 +226,7 @@ def handle_request(self, request: Request) -> Response: # Connect to the remote host using socks5 kwargs = { "stream": stream, - "host": self._remote_origin.host.decode("ascii"), + "host": self._remote_origin.normalized_host, "port": self._remote_origin.port, "auth": self._proxy_auth, } @@ -242,7 +242,7 @@ def handle_request(self, request: Request) -> Response: kwargs = { "ssl_context": ssl_context, - "server_hostname": sni_hostname or self._remote_origin.host.decode("ascii"), + "server_hostname": sni_hostname or self._remote_origin.normalized_host, "timeout": timeout, } with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/tests/httpcore2/_async/test_trailing_dot.py b/tests/httpcore2/_async/test_trailing_dot.py new file mode 100644 index 00000000..4144d319 --- /dev/null +++ b/tests/httpcore2/_async/test_trailing_dot.py @@ -0,0 +1,70 @@ +"""Async tests for trailing-dot FQDN hostname normalisation (issue #1063).""" + +from __future__ import annotations + +import ssl +import typing + +import pytest + +import httpcore2 +from httpcore2 import ( + SOCKET_OPTION, + AsyncMockBackend, + AsyncMockStream, + AsyncNetworkStream, + Origin, +) + + +class RecordingAsyncStream(AsyncMockStream): + """AsyncMockStream that records the server_hostname passed to start_tls().""" + + def __init__(self, buffer: list[bytes]) -> None: + super().__init__(buffer) + self.start_tls_hostname: str | None = None + + async def start_tls( + self, + ssl_context: ssl.SSLContext, + server_hostname: str | None = None, + timeout: float | None = None, + ) -> AsyncNetworkStream: + self.start_tls_hostname = server_hostname + return self + + +class RecordingAsyncBackend(AsyncMockBackend): + def __init__(self, buffer: list[bytes]) -> None: + super().__init__(buffer) + self.stream: RecordingAsyncStream | None = None + + async def connect_tcp( + self, + host: str, + port: int, + timeout: float | None = None, + local_address: str | None = None, + socket_options: typing.Iterable[SOCKET_OPTION] | None = None, + ) -> AsyncNetworkStream: + self.stream = RecordingAsyncStream(list(self._buffer)) + return self.stream + + +@pytest.mark.anyio +async def test_sni_hostname_strips_trailing_dot() -> None: + """server_hostname passed to start_tls() must not carry the trailing dot.""" + origin = Origin(b"https", b"myhost.internal.", 443) + network_backend = RecordingAsyncBackend( + [ + b"HTTP/1.1 200 OK\r\n", + b"Content-Length: 0\r\n", + b"\r\n", + ] + ) + async with httpcore2.AsyncHTTPConnection(origin=origin, network_backend=network_backend) as conn: + async with conn.stream("GET", "https://myhost.internal./") as response: + await response.aread() + + assert network_backend.stream is not None + assert network_backend.stream.start_tls_hostname == "myhost.internal" diff --git a/tests/httpcore2/_sync/test_trailing_dot.py b/tests/httpcore2/_sync/test_trailing_dot.py new file mode 100644 index 00000000..54054c33 --- /dev/null +++ b/tests/httpcore2/_sync/test_trailing_dot.py @@ -0,0 +1,70 @@ +"""Async tests for trailing-dot FQDN hostname normalisation (issue #1063).""" + +from __future__ import annotations + +import ssl +import typing + +import pytest + +import httpcore2 +from httpcore2 import ( + SOCKET_OPTION, + MockBackend, + MockStream, + NetworkStream, + Origin, +) + + +class RecordingAsyncStream(MockStream): + """MockStream that records the server_hostname passed to start_tls().""" + + def __init__(self, buffer: list[bytes]) -> None: + super().__init__(buffer) + self.start_tls_hostname: str | None = None + + def start_tls( + self, + ssl_context: ssl.SSLContext, + server_hostname: str | None = None, + timeout: float | None = None, + ) -> NetworkStream: + self.start_tls_hostname = server_hostname + return self + + +class RecordingAsyncBackend(MockBackend): + def __init__(self, buffer: list[bytes]) -> None: + super().__init__(buffer) + self.stream: RecordingAsyncStream | None = None + + def connect_tcp( + self, + host: str, + port: int, + timeout: float | None = None, + local_address: str | None = None, + socket_options: typing.Iterable[SOCKET_OPTION] | None = None, + ) -> NetworkStream: + self.stream = RecordingAsyncStream(list(self._buffer)) + return self.stream + + + +def test_sni_hostname_strips_trailing_dot() -> None: + """server_hostname passed to start_tls() must not carry the trailing dot.""" + origin = Origin(b"https", b"myhost.internal.", 443) + network_backend = RecordingAsyncBackend( + [ + b"HTTP/1.1 200 OK\r\n", + b"Content-Length: 0\r\n", + b"\r\n", + ] + ) + with httpcore2.HTTPConnection(origin=origin, network_backend=network_backend) as conn: + with conn.stream("GET", "https://myhost.internal./") as response: + response.read() + + assert network_backend.stream is not None + assert network_backend.stream.start_tls_hostname == "myhost.internal" diff --git a/tests/httpcore2/test_models.py b/tests/httpcore2/test_models.py index 62555fd0..08f08e7f 100644 --- a/tests/httpcore2/test_models.py +++ b/tests/httpcore2/test_models.py @@ -49,6 +49,31 @@ def test_url_origin_socks5() -> None: assert str(origin) == "socks5h://127.0.0.1:1080" +def test_origin_str_strips_trailing_dot() -> None: + """Origin.__str__ must strip the trailing dot from FQDNs. + + 'myhost.internal.' is a valid FQDN but TLS certificates use + 'myhost.internal' (without the dot). Passing the raw hostname + to ssl_wrap_socket would cause CERTIFICATE_VERIFY_FAILED. + """ + origin = httpcore2.Origin(b"https", b"myhost.internal.", 443) + assert str(origin) == "https://myhost.internal:443" + + +def test_origin_str_no_trailing_dot_unchanged() -> None: + origin = httpcore2.Origin(b"https", b"example.com", 443) + assert str(origin) == "https://example.com:443" + + +def test_url_host_strips_trailing_dot() -> None: + """URL.host used for SNI should not carry the trailing dot.""" + url = httpcore2.URL("https://myhost.internal.:8443/") + assert url.host == b"myhost.internal." + assert url.port is not None + origin = httpcore2.Origin(b"https", url.host, url.port) + assert str(origin) == "https://myhost.internal:8443" + + # Request