Skip to content

Commit 3d72077

Browse files
committed
Require exactly one pair of brackets around IP literals
hostname() stripped every leading "[" and trailing "]" from a host followed by a port, then validated what was left as a simple hostname, domain, IPv4 or IPv6 address. "[1.2.3.4]:80", "[example.com]:80", "[[::1]]:80" and "::1]:80" all validated. RFC 3986 (3.2.2) only encloses an IPv6 (or IPvFuture) address in brackets, so a bracketed host must now be exactly one pair around an IPv6 address. email() likewise stripped repeated brackets from an address literal, accepting "user@[[192.0.2.1]]" and "user@[192.0.2.1]]". It now strips exactly one pair. Inside the pair, any further bracket is rejected, since ipv6() accepts "]" as part of a zone ID ("::1%]").
1 parent 22786cc commit 3d72077

4 files changed

Lines changed: 66 additions & 2 deletions

File tree

‎src/validators/email.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ def email(
6767
if ipv6_address or ipv4_address:
6868
if domain_part.startswith("[") and domain_part.endswith("]"):
6969
# ref: RFC 5321
70-
domain_part = domain_part.lstrip("[").rstrip("]")
70+
domain_part = domain_part[1:-1]
71+
if "[" in domain_part or "]" in domain_part:
72+
return False
7173
else:
7274
return False
7375

‎src/validators/hostname.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def _port_validator(value: str):
3737
# with ipv6
3838
host_seg, port_seg = value.rsplit(":", 1)
3939
if _port_regex().fullmatch(f":{port_seg}"):
40-
return host_seg.lstrip("[").rstrip("]")
40+
return host_seg
4141

4242
if value.count(":") == 1:
4343
# with ipv4 or simple hostname
@@ -125,6 +125,12 @@ def _matches_simple_hostname(host: str):
125125
return _simple_hostname_regex().fullmatch(host)
126126

127127
if may_have_port and (host_seg := _port_validator(value)):
128+
if host_seg.startswith("[") and host_seg.endswith("]"):
129+
# ref: RFC 3986 (3.2.2), one pair of brackets encloses an IPv6 address
130+
host_seg = host_seg[1:-1]
131+
if skip_ipv6_addr or "[" in host_seg or "]" in host_seg:
132+
return False
133+
return ipv6(host_seg, cidr=False)
128134
return (
129135
_matches_simple_hostname(host_seg)
130136
or domain(host_seg, consider_tld=consider_tld, rfc_1034=rfc_1034, rfc_2782=rfc_2782)

‎tests/test_email.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,33 @@ def test_rejects_newline_after_local_part(local_part: str):
7272
def test_returns_failed_validation_on_idna_mapped_lookalike_domain(domain_part: str):
7373
"""Test returns failed validation on email domains that only IDNA 2003 mapping makes valid."""
7474
assert isinstance(email(f"user@{domain_part}"), ValidationError)
75+
76+
77+
@pytest.mark.parametrize(
78+
("value", "ipv6_address", "ipv4_address"),
79+
[
80+
("user@[192.0.2.1]", False, True),
81+
("user@[2001:db8::1]", True, False),
82+
],
83+
)
84+
def test_returns_true_on_address_literal(value: str, ipv6_address: bool, ipv4_address: bool):
85+
"""Test returns true on an email whose domain is a bracketed IP address."""
86+
assert email(value, ipv6_address=ipv6_address, ipv4_address=ipv4_address)
87+
88+
89+
@pytest.mark.parametrize(
90+
"value",
91+
[
92+
"user@[[192.0.2.1]]",
93+
"user@[192.0.2.1]]",
94+
"user@[[192.0.2.1]",
95+
"user@[[2001:db8::1]]",
96+
"user@[[[2001:db8::1]]]",
97+
"user@[2001:db8::1]]",
98+
"user@[2001:db8::1%]]",
99+
"user@[]",
100+
],
101+
)
102+
def test_returns_failed_validation_on_address_literal_with_extra_brackets(value: str):
103+
"""Test returns failed validation on an address literal with more than one pair of brackets."""
104+
assert isinstance(email(value, ipv6_address=True, ipv4_address=True), ValidationError)

‎tests/test_hostname.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,29 @@ def test_returns_true_on_valid_top_level_domain_hostname(value: str):
171171
def test_returns_failed_validation_on_invalid_top_level_domain_hostname(value: str):
172172
"""Test returns failed validation on hostname with a TLD not allowed by IANA."""
173173
assert isinstance(hostname(value, consider_tld=True), ValidationError)
174+
175+
176+
@pytest.mark.parametrize(
177+
"value",
178+
[
179+
# ref: RFC 3986 (3.2.2), only an IPv6 address is enclosed in brackets
180+
"[1.2.3.4]:80",
181+
"[example.com]:80",
182+
"[localhost]:80",
183+
# exactly one pair of brackets
184+
"[[::1]]:80",
185+
"[[::1]:80",
186+
"[::1]]:80",
187+
"::1]:80",
188+
"localhost]:80",
189+
"[::1%]]:80",
190+
],
191+
)
192+
def test_returns_failed_validation_on_malformed_bracketed_host(value: str):
193+
"""Test returns failed validation on brackets that do not enclose one IPv6 address."""
194+
assert isinstance(hostname(value), ValidationError)
195+
196+
197+
def test_returns_failed_validation_on_bracketed_ipv6_when_skipped():
198+
"""Test returns failed validation on a bracketed IPv6 address when IPv6 is skipped."""
199+
assert isinstance(hostname("[::1]:80", skip_ipv6_addr=True), ValidationError)

0 commit comments

Comments
 (0)