Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/httpcore2/httpcore2/_async/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/httpcore2/httpcore2/_async/http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions src/httpcore2/httpcore2/_async/socks_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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,
}
Expand All @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions src/httpcore2/httpcore2/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/httpcore2/httpcore2/_sync/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/httpcore2/httpcore2/_sync/http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions src/httpcore2/httpcore2/_sync/socks_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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,
}
Expand All @@ -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:
Expand Down
70 changes: 70 additions & 0 deletions tests/httpcore2/_async/test_trailing_dot.py
Original file line number Diff line number Diff line change
@@ -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"
70 changes: 70 additions & 0 deletions tests/httpcore2/_sync/test_trailing_dot.py
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions tests/httpcore2/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading