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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Preserve absolute hostnames during DNS resolution

With a DNS search suffix and ndots:5, stripping the dot changes http://api.example.com./ from an absolute lookup into a search-path lookup, allowing an attacker controlling api.example.com.<search-suffix> to receive the request, including Authorization headers and request bodies. Preserve the raw hostname for TCP and SOCKS destination resolution in both sync and async paths; strip the dot only for TLS server_hostname.

Suggested change
"host": self._origin.normalized_host,
"host": self._origin.host.decode("ascii"),

"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 @@ -296,7 +296,7 @@ async def handle_async_request(self, request: Request) -> Response:
alpn_protocols = (["h2", "http/1.1"] if self._http1 else ["h2"]) if self._http2 else ["http/1.1"]
ssl_context.set_alpn_protocols(alpn_protocols)

server_hostname = request.extensions.get("sni_hostname") or self._remote_origin.host.decode("ascii")
server_hostname = request.extensions.get("sni_hostname") or self._remote_origin.normalized_host
kwargs = {
"ssl_context": ssl_context,
"server_hostname": server_hostname,
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 @@ -219,7 +219,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 @@ -230,7 +230,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,
"timeouts": timeouts,
Expand All @@ -249,7 +249,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(".")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Stripping the final dot before DNS resolution turns an absolute hostname into a search-list candidate. With resolver settings such as ndots:5, myhost.internal. may resolve as myhost.internal.<search-domain> instead of the requested host; preserve the dot for DNS/SOCKS resolution and strip it only for TLS SNI.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/httpcore2/httpcore2/_models.py, line 173:

<comment>Stripping the final dot before DNS resolution turns an absolute hostname into a search-list candidate. With resolver settings such as `ndots:5`, `myhost.internal.` may resolve as `myhost.internal.<search-domain>` instead of the requested host; preserve the dot for DNS/SOCKS resolution and strip it only for TLS SNI.</comment>

<file context>
@@ -161,6 +161,17 @@ def __init__(self, scheme: bytes, host: bytes, port: int) -> None:
+        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:
</file context>


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 @@ -296,7 +296,7 @@ def handle_request(self, request: Request) -> Response:
alpn_protocols = (["h2", "http/1.1"] if self._http1 else ["h2"]) if self._http2 else ["http/1.1"]
ssl_context.set_alpn_protocols(alpn_protocols)

server_hostname = request.extensions.get("sni_hostname") or self._remote_origin.host.decode("ascii")
server_hostname = request.extensions.get("sni_hostname") or self._remote_origin.normalized_host
kwargs = {
"ssl_context": ssl_context,
"server_hostname": server_hostname,
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 @@ -219,7 +219,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 @@ -230,7 +230,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,
"timeouts": timeouts,
Expand All @@ -249,7 +249,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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The three SOCKS5 changes (proxy connect host, SOCKS5 CONNECT target host, and server_hostname) have no test. Add a case to the SOCKS test files with an origin like https://myhost.internal./ that asserts the stripped host reaches connect_tcp, the SOCKS5 request, and start_tls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/httpcore2/httpcore2/_sync/socks_proxy.py, line 252:

<comment>The three SOCKS5 changes (proxy connect host, SOCKS5 CONNECT target host, and `server_hostname`) have no test. Add a case to the SOCKS test files with an origin like `https://myhost.internal./` that asserts the stripped host reaches `connect_tcp`, the SOCKS5 request, and `start_tls`.</comment>

<file context>
@@ -249,7 +249,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,
                         }
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The sni_hostname override bypasses the new normalization. An explicit sni_hostname="myhost.internal." still sends the trailing dot to start_tls, which reproduces the Host name mismatch this PR fixes. Consider stripping the trailing dot from the override too, or documenting that the override must not include it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/httpcore2/httpcore2/_sync/socks_proxy.py, line 252:

<comment>The `sni_hostname` override bypasses the new normalization. An explicit `sni_hostname="myhost.internal."` still sends the trailing dot to `start_tls`, which reproduces the Host name mismatch this PR fixes. Consider stripping the trailing dot from the override too, or documenting that the override must not include it.</comment>

<file context>
@@ -249,7 +249,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,
                         }
</file context>

"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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: connect_tcp ignores its host argument, so the test never checks the DNS/connect path this PR changes. Record host in the backend and assert it equals "myhost.internal".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/httpcore2/_async/test_trailing_dot.py, line 50:

<comment>`connect_tcp` ignores its `host` argument, so the test never checks the DNS/connect path this PR changes. Record `host` in the backend and assert it equals `"myhost.internal"`.</comment>

<file context>
@@ -0,0 +1,70 @@
+        local_address: str | None = None,
+        socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
+    ) -> AsyncNetworkStream:
+        self.stream = RecordingAsyncStream(list(self._buffer))
+        return self.stream
+
</file context>

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."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The docstring and test name say URL.host strips the trailing dot, but the assertion checks that url.host keeps it (b"myhost.internal."). Rename the test to test_url_host_preserves_trailing_dot and fix the docstring to say the raw host keeps the dot and only Origin.__str__ strips it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/httpcore2/test_models.py, line 69:

<comment>The docstring and test name say `URL.host` strips the trailing dot, but the assertion checks that `url.host` keeps it (`b"myhost.internal."`). Rename the test to `test_url_host_preserves_trailing_dot` and fix the docstring to say the raw host keeps the dot and only `Origin.__str__` strips it.</comment>

<file context>
@@ -49,6 +49,31 @@ def test_url_origin_socks5() -> None:
+
+
+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."
</file context>
Suggested change
"""URL.host used for SNI should not carry the trailing dot."""
"""URL.host keeps the raw trailing dot; Origin.__str__ strips it for SNI."""

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