Skip to content

strip trailing dot from FQDN hostnames for TLS SNI - #1007

Open
mbeijen wants to merge 3 commits into
pydantic:mainfrom
mbeijen:fix/trailing-dot-fqdn-sni
Open

mbeijen wants to merge 3 commits into
pydantic:mainfrom
mbeijen:fix/trailing-dot-fqdn-sni

Conversation

@mbeijen

@mbeijen mbeijen commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

A request to an FQDN with a trailing dot, such as https://myhost.internal./, is valid DNS. But the dot was passed through to TLS SNI and certificate verification, which fail with CERTIFICATE_VERIFY_FAILED: Host name mismatch because certificates never carry the dot. The raw Origin.host bytes are kept as-is, so connection pooling and equality are unchanged; only the hostname handed to the network layer is normalized.

Fixes encode/httpcore#1063. Follows the approach of encode/httpcore#1068 (naarob, also the author of the httpcorexyz commit this is ported from). Alternatives were proposed in encode/httpcore#1067, encode/httpcore#1074 and encode/httpcore#1086.

Note: this change was prepared with AI assistance (Claude Code).

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 through to
the SSL handshake causes CERTIFICATE_VERIFY_FAILED: Host name mismatch.

Add Origin.normalized_host as the single source of truth: it decodes the
host bytes to ASCII and strips any trailing FQDN dot. All connection,
proxy, and SOCKS callsites now use origin.normalized_host, and
Origin.__str__ does the same. Raw Origin.host bytes are preserved.

Fixes encode/httpcore#1063. Adopts the approach of upstream PR
encode/httpcore#1068 (same author as the httpcorexyz fork commit);
see also alternative proposals pydantic#1067, pydantic#1074, pydantic#1086.

Co-Authored-By: naarob <laforge@forge-sync.local>
@codspeed

codspeed Bot commented Jun 1, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 18 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing mbeijen:fix/trailing-dot-fqdn-sni (6912b43) with main (2fc9158)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

No issues found across 10 files

Re-trigger cubic

@mbeijen mbeijen changed the title httpcore2: strip trailing dot from FQDN hostnames for TLS SNI strip trailing dot from FQDN hostnames for TLS SNI Jun 1, 2026
…dn-sni

# Conflicts:
#	src/httpcore2/httpcore2/_async/http_proxy.py
#	src/httpcore2/httpcore2/_sync/http_proxy.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

5 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. 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. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/httpcore2/test_models.py">

<violation number="1" location="tests/httpcore2/test_models.py:69">
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.</violation>
</file>

<file name="tests/httpcore2/_async/test_trailing_dot.py">

<violation number="1" location="tests/httpcore2/_async/test_trailing_dot.py:50">
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"`.</violation>
</file>

<file name="src/httpcore2/httpcore2/_sync/socks_proxy.py">

<violation number="1" location="src/httpcore2/httpcore2/_sync/socks_proxy.py:252">
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.</violation>

<violation number="2" location="src/httpcore2/httpcore2/_sync/socks_proxy.py:252">
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`.</violation>
</file>

<file name="src/httpcore2/httpcore2/_models.py">

<violation number="1" location="src/httpcore2/httpcore2/_models.py:173">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

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>

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>

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

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.

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>

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

@veria-ai

veria-ai Bot commented Oct 25, 2026

Copy link
Copy Markdown

PR overview

Updates hostname handling to strip trailing dots from fully qualified domain names for TLS SNI.

One issue remains open: removing the trailing dot before destination resolution can turn an absolute hostname into a DNS search-path lookup. With a search suffix and settings such as ndots:5, an attacker controlling the resulting search-suffixed hostname could receive requests, including Authorization headers and bodies. The original hostname needs to be preserved for TCP and SOCKS resolution, with dot stripping limited to TLS SNI.

Open issues (1)

Fixed/addressed: 0 · PR risk: 6/10

mbeijen added a commit to mbeijen/httpx2 that referenced this pull request Oct 25, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant