Skip to content

Commit 52c4fa5

Browse files
Accept trailing-dot FQDN in address parser
RFC 1034 §3.1 permits a single trailing dot to mark a hostname as root-anchored (disabling resolver search-list expansion); RFC 3986 §3.2.2 explicitly allows it in URI ``reg-name``. The hostname-label regex required every label to be followed by another label, rejecting the rooted form. Loosen the regex to accept an optional final dot. Strip the trailing dot in the canonical form so the rooted and unrooted surface variants canonicalise identically — allowlist policies holding one form continue to match the other. Double trailing dots (``foo..``) and a bare ``.`` remain rejected.
1 parent 1cc175f commit 52c4fa5

2 files changed

Lines changed: 68 additions & 4 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -546,10 +546,16 @@ def _is_no_tx_rollback_error(exc: BaseException) -> bool:
546546

547547
# RFC 1035 hostname labels are ASCII letters, digits, and hyphen. We
548548
# accept a dotted sequence of labels up to 253 chars total. Single
549-
# labels (e.g. "localhost") are also accepted.
549+
# labels (e.g. "localhost") are also accepted. RFC 1034 §3.1 permits a
550+
# single trailing dot to mark the FQDN as root-anchored (disabling
551+
# resolver search-list expansion); RFC 3986 §3.2.2 permits the same
552+
# in URI ``reg-name``. Match both forms; the canonical form drops
553+
# the trailing dot so two surface variants of the same FQDN
554+
# canonicalise identically for allowlist comparisons.
550555
_HOSTNAME_LABEL_RE = re.compile(
551-
r"^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)"
552-
r"(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$"
556+
r"^(?=.{1,254}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)"
557+
r"(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*"
558+
r"\.?$"
553559
)
554560

555561

@@ -585,7 +591,11 @@ def _canonicalize_host(host: str, address: str) -> str:
585591
raise ValueError(
586592
f"Invalid host in address {address!r}: {host!r} is not a valid hostname or IP literal"
587593
)
588-
return host.lower()
594+
# Strip a trailing dot from FQDNs (e.g. ``foo.example.com.``) so
595+
# the rooted and unrooted forms canonicalise identically — a
596+
# server-supplied redirect target and the operator's allowlist
597+
# entry should match regardless of which form was written.
598+
return host.rstrip(".").lower()
589599

590600

591601
def _validate_timeout(value: float, *, name: str = "timeout") -> float:
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Pin: ``_parse_address`` accepts a trailing-dot FQDN (RFC 1034 §3.1
2+
root-anchored notation). The hostname-label regex previously rejected
3+
the form, contradicting RFC 3986 §3.2.2's ``reg-name`` permission.
4+
5+
Canonical form drops the trailing dot so two surface variants of the
6+
same FQDN (rooted ``foo.example.com.`` and unrooted ``foo.example.com``)
7+
canonicalise identically for allowlist comparisons.
8+
9+
A double trailing dot (``foo..``) remains invalid.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import pytest
15+
16+
from dqliteclient.connection import _parse_address
17+
18+
19+
@pytest.mark.parametrize(
20+
"addr",
21+
[
22+
"foo.example.com.:9001",
23+
"localhost.:9001",
24+
"single-label.:9001",
25+
"a.b.c.:9001",
26+
],
27+
)
28+
def test_parse_address_accepts_trailing_dot_fqdn(addr: str) -> None:
29+
host, port = _parse_address(addr)
30+
# Canonical form drops the trailing dot.
31+
assert not host.endswith(".")
32+
assert port == 9001
33+
34+
35+
def test_parse_address_canonicalises_rooted_and_unrooted_to_same_tuple() -> None:
36+
"""RFC 1034 root-anchored form and the bare form must canonicalise
37+
identically — allowlist policies hold one or the other; both
38+
surface variants must compare equal."""
39+
a = _parse_address("foo.example.com.:9001")
40+
b = _parse_address("foo.example.com:9001")
41+
assert a == b
42+
43+
44+
def test_parse_address_rejects_double_trailing_dot() -> None:
45+
"""``foo..`` is malformed (empty inner label) — must remain a
46+
ValueError."""
47+
with pytest.raises(ValueError):
48+
_parse_address("foo..:9001")
49+
50+
51+
def test_parse_address_rejects_lone_dot() -> None:
52+
"""A bare ``.`` is not a valid hostname."""
53+
with pytest.raises(ValueError):
54+
_parse_address(".:9001")

0 commit comments

Comments
 (0)