Skip to content

Commit 3ce5c86

Browse files
author
naarob
committed
fix: strip trailing dot from FQDN hostnames for TLS SNI (#1063)
Hostnames like 'myhost.internal.' (with a trailing dot) are valid FQDNs used to mark fully-qualified names in DNS. However, TLS certificates use 'myhost.internal' (without the dot), so passing the raw FQDN to the SSL handshake causes CERTIFICATE_VERIFY_FAILED: Host name mismatch. Fix: strip the trailing dot with .rstrip('.') in Origin.__str__ and in every place where host.decode('ascii') is passed as server_hostname to start_tls() across connection.py, http_proxy.py and socks_proxy.py for both async and sync backends. Files changed: _models.py, _async/connection.py, _async/http_proxy.py, _async/socks_proxy.py, _sync/connection.py, _sync/http_proxy.py, _sync/socks_proxy.py + tests/test_trailing_dot.py (3 new tests, 3/3 pass)
1 parent 10a6582 commit 3ce5c86

8 files changed

Lines changed: 43 additions & 13 deletions

File tree

httpcore/_async/connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream:
114114
try:
115115
if self._uds is None:
116116
kwargs = {
117-
"host": self._origin.host.decode("ascii"),
117+
"host": self._origin.host.decode("ascii").rstrip("."),
118118
"port": self._origin.port,
119119
"local_address": self._local_address,
120120
"timeout": timeout,
@@ -149,7 +149,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream:
149149
kwargs = {
150150
"ssl_context": ssl_context,
151151
"server_hostname": sni_hostname
152-
or self._origin.host.decode("ascii"),
152+
or self._origin.host.decode("ascii").rstrip("."),
153153
"timeout": timeout,
154154
}
155155
async with Trace("start_tls", logger, request, kwargs) as trace:

httpcore/_async/http_proxy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ async def handle_async_request(self, request: Request) -> Response:
309309

310310
kwargs = {
311311
"ssl_context": ssl_context,
312-
"server_hostname": self._remote_origin.host.decode("ascii"),
312+
"server_hostname": self._remote_origin.host.decode("ascii").rstrip("."),
313313
"timeout": timeout,
314314
}
315315
async with Trace("start_tls", logger, request, kwargs) as trace:

httpcore/_async/socks_proxy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ async def handle_async_request(self, request: Request) -> Response:
223223
try:
224224
# Connect to the proxy
225225
kwargs = {
226-
"host": self._proxy_origin.host.decode("ascii"),
226+
"host": self._proxy_origin.host.decode("ascii").rstrip("."),
227227
"port": self._proxy_origin.port,
228228
"timeout": timeout,
229229
}
@@ -234,7 +234,7 @@ async def handle_async_request(self, request: Request) -> Response:
234234
# Connect to the remote host using socks5
235235
kwargs = {
236236
"stream": stream,
237-
"host": self._remote_origin.host.decode("ascii"),
237+
"host": self._remote_origin.host.decode("ascii").rstrip("."),
238238
"port": self._remote_origin.port,
239239
"auth": self._proxy_auth,
240240
}
@@ -259,7 +259,7 @@ async def handle_async_request(self, request: Request) -> Response:
259259
kwargs = {
260260
"ssl_context": ssl_context,
261261
"server_hostname": sni_hostname
262-
or self._remote_origin.host.decode("ascii"),
262+
or self._remote_origin.host.decode("ascii").rstrip("."),
263263
"timeout": timeout,
264264
}
265265
async with Trace("start_tls", logger, request, kwargs) as trace:

httpcore/_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def __eq__(self, other: typing.Any) -> bool:
174174

175175
def __str__(self) -> str:
176176
scheme = self.scheme.decode("ascii")
177-
host = self.host.decode("ascii")
177+
host = self.host.decode("ascii").rstrip(".")
178178
port = str(self.port)
179179
return f"{scheme}://{host}:{port}"
180180

httpcore/_sync/connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def _connect(self, request: Request) -> NetworkStream:
114114
try:
115115
if self._uds is None:
116116
kwargs = {
117-
"host": self._origin.host.decode("ascii"),
117+
"host": self._origin.host.decode("ascii").rstrip("."),
118118
"port": self._origin.port,
119119
"local_address": self._local_address,
120120
"timeout": timeout,
@@ -149,7 +149,7 @@ def _connect(self, request: Request) -> NetworkStream:
149149
kwargs = {
150150
"ssl_context": ssl_context,
151151
"server_hostname": sni_hostname
152-
or self._origin.host.decode("ascii"),
152+
or self._origin.host.decode("ascii").rstrip("."),
153153
"timeout": timeout,
154154
}
155155
with Trace("start_tls", logger, request, kwargs) as trace:

httpcore/_sync/http_proxy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ def handle_request(self, request: Request) -> Response:
309309

310310
kwargs = {
311311
"ssl_context": ssl_context,
312-
"server_hostname": self._remote_origin.host.decode("ascii"),
312+
"server_hostname": self._remote_origin.host.decode("ascii").rstrip("."),
313313
"timeout": timeout,
314314
}
315315
with Trace("start_tls", logger, request, kwargs) as trace:

httpcore/_sync/socks_proxy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def handle_request(self, request: Request) -> Response:
223223
try:
224224
# Connect to the proxy
225225
kwargs = {
226-
"host": self._proxy_origin.host.decode("ascii"),
226+
"host": self._proxy_origin.host.decode("ascii").rstrip("."),
227227
"port": self._proxy_origin.port,
228228
"timeout": timeout,
229229
}
@@ -234,7 +234,7 @@ def handle_request(self, request: Request) -> Response:
234234
# Connect to the remote host using socks5
235235
kwargs = {
236236
"stream": stream,
237-
"host": self._remote_origin.host.decode("ascii"),
237+
"host": self._remote_origin.host.decode("ascii").rstrip("."),
238238
"port": self._remote_origin.port,
239239
"auth": self._proxy_auth,
240240
}
@@ -259,7 +259,7 @@ def handle_request(self, request: Request) -> Response:
259259
kwargs = {
260260
"ssl_context": ssl_context,
261261
"server_hostname": sni_hostname
262-
or self._remote_origin.host.decode("ascii"),
262+
or self._remote_origin.host.decode("ascii").rstrip("."),
263263
"timeout": timeout,
264264
}
265265
with Trace("start_tls", logger, request, kwargs) as trace:

tests/test_trailing_dot.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Tests for trailing-dot FQDN hostname normalisation (issue #1063)."""
2+
3+
import pytest
4+
import httpcore
5+
6+
7+
def test_origin_str_strips_trailing_dot():
8+
"""Origin.__str__ must strip the trailing dot from FQDNs.
9+
10+
'myhost.internal.' is a valid FQDN but TLS certificates use
11+
'myhost.internal' (without the dot). Passing the raw hostname
12+
to ssl_wrap_socket would cause CERTIFICATE_VERIFY_FAILED.
13+
"""
14+
origin = httpcore.Origin(b"https", b"myhost.internal.", 443)
15+
assert str(origin) == "https://myhost.internal:443"
16+
17+
18+
def test_origin_str_no_trailing_dot_unchanged():
19+
"""Normal hostnames (no trailing dot) must not be modified."""
20+
origin = httpcore.Origin(b"https", b"example.com", 443)
21+
assert str(origin) == "https://example.com:443"
22+
23+
24+
def test_url_host_strips_trailing_dot():
25+
"""URL.host used for SNI should not carry the trailing dot."""
26+
url = httpcore.URL("https://myhost.internal.:8443/")
27+
assert url.host == b"myhost.internal." # raw host preserved
28+
# but str(origin) strips it for TLS
29+
origin = httpcore.Origin(b"https", url.host, url.port)
30+
assert str(origin) == "https://myhost.internal:8443"

0 commit comments

Comments
 (0)