Skip to content

Commit 213f2ce

Browse files
committed
Reject control and whitespace characters in IPv6 zone IDs
ipv6() hands its value to ipaddress, which accepts any non-empty zone ID without a "%", so "fe80::1%\0", "fe80::1%\n" and "fe80::1%a b" validated, and through hostname() so did "[fe80::1%a b]:22". RFC 4007 sets no character set for a zone ID, and RFC 9844 (section 6) leaves the checks to each implementation, naming NUL as one to reject. Reject zone IDs containing ASCII or Unicode control characters or whitespace. Numeric and interface-name zone IDs such as "eth0.100" still validate. email() accepted an IPv6 address literal with a zone ID, such as "user@[fe80::1%eth0]". RFC 5321 (4.1.3) defines IPv6-addr without a zone, so a "%" in the domain part is now rejected.
1 parent 22786cc commit 213f2ce

5 files changed

Lines changed: 60 additions & 0 deletions

File tree

‎src/validators/email.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ def email(
6060

6161
username_part, domain_part = value.rsplit("@", 1)
6262

63+
if "%" in domain_part:
64+
# ref: RFC 5321 (4.1.3), an address literal has no zone ID
65+
return False
66+
6367
if len(username_part) > 64 or len(domain_part) > 253:
6468
# ref: RFC 1034 and 5231
6569
return False

‎src/validators/ip_address.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
import re
1313
from typing import Optional
14+
from unicodedata import category
1415

1516
# local
1617
from .utils import validator
@@ -129,6 +130,10 @@ def ipv6(value: str, /, *, cidr: bool = True, strict: bool = False, host_bit: bo
129130
"""
130131
if not value:
131132
return False
133+
# ref: RFC 9844 (6), RFC 4007 sets no character set for a zone ID, so reject
134+
# the control and whitespace characters that break later string processing
135+
if any(char.isspace() or category(char) == "Cc" for char in value.partition("%")[2]):
136+
return False
132137
try:
133138
if cidr:
134139
if strict and value.count("/") != 1:

‎tests/test_email.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,14 @@ 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("value", ["user@[fe80::1%eth0]", "user@[fe80::1%1]"])
78+
def test_returns_failed_validation_on_address_literal_with_zone_id(value: str):
79+
"""Test returns failed validation on an IPv6 address literal with a zone ID."""
80+
assert isinstance(email(value, ipv6_address=True), ValidationError)
81+
82+
83+
def test_returns_true_on_ipv6_address_literal_without_zone_id():
84+
"""Test returns true on an IPv6 address literal without a zone ID."""
85+
assert email("user@[fe80::1]", ipv6_address=True)

‎tests/test_hostname.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,14 @@ 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+
def test_returns_true_on_bracketed_ipv6_with_zone_id():
177+
"""Test returns true on a bracketed link-local IPv6 address with a zone ID."""
178+
assert hostname("[fe80::1%eth0]:22")
179+
180+
181+
@pytest.mark.parametrize("value", ["[fe80::1%eth0 ]:22", "[fe80::1%\n]:22", "fe80::1%a b"])
182+
def test_returns_failed_validation_on_zone_id_with_control_or_space(value: str):
183+
"""Test returns failed validation on a zone ID with a control or whitespace character."""
184+
assert isinstance(hostname(value), ValidationError)

‎tests/test_ip_address.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,32 @@ def test_returns_true_on_valid_public_ipv4_address(address: str, private: bool):
204204
def test_returns_failed_validation_on_invalid_public_ipv4_address(address: str, private: bool):
205205
"""Test returns failed validation on private ipv4 address."""
206206
assert isinstance(ipv4(address, private=private), ValidationError)
207+
208+
209+
@pytest.mark.parametrize(
210+
"zone_id",
211+
[
212+
"\x00",
213+
"\t",
214+
"\n",
215+
"\r",
216+
"\x7f",
217+
"\x85", # next line, a C1 control
218+
" ",
219+
"eth0 ",
220+
"a b",
221+
"\u3000", # ideographic space
222+
"\u2028", # line separator
223+
],
224+
)
225+
@pytest.mark.parametrize("cidr", [False, True])
226+
def test_returns_failed_validation_on_zone_id_with_control_or_space(zone_id: str, cidr: bool):
227+
"""Test returns failed validation on a zone ID with a control or whitespace character."""
228+
assert isinstance(ipv6(f"fe80::1%{zone_id}", cidr=cidr), ValidationError)
229+
230+
231+
@pytest.mark.parametrize("zone_id", ["1", "eth0", "eth0.100", "br-lan", "en0", "25eth0"])
232+
@pytest.mark.parametrize("cidr", [False, True])
233+
def test_returns_true_on_zone_id(zone_id: str, cidr: bool):
234+
"""Test returns true on numeric and interface-name zone IDs."""
235+
assert ipv6(f"fe80::1%{zone_id}", cidr=cidr)

0 commit comments

Comments
 (0)