Skip to content

Reject control and whitespace characters in IPv6 zone IDs - #537

Open
nandgator wants to merge 1 commit into
masterfrom
fix-ipv6-zone-id-controls
Open

nandgator wants to merge 1 commit into
masterfrom
fix-ipv6-zone-id-controls

Conversation

@nandgator

@nandgator nandgator commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

ipv6() passes its value to ipaddress, which accepts any zone ID that is non-empty and has no % (docs). So NUL, tab, newline and spaces all validate, and hostname() inherits them:

>>> validators.ipv6("fe80::1%" + chr(0)), validators.ipv6("fe80::1%a b")
(True, True)
>>> validators.hostname("[fe80::1%a b]:22")
True

RFC 4007 defines no character set for a zone ID. RFC 9844 section 6, which obsoleted RFC 6874 in August 2025, leaves the checks to each implementation, "appropriate for the environment it is used in", and gives NUL as an example to reject.

email() also accepts an IPv6 address literal with a zone ID, such as user@[fe80::1%eth0] with ipv6_address=True. The IPv6-addr rule in RFC 5321 section 4.1.3 has no zone.

Changes

  • ipv6() rejects a zone ID containing a control character (Unicode category Cc, which covers C0, DEL and C1) or whitespace (str.isspace()). Numeric and interface-name zone IDs like 1, eth0, eth0.100 and br-lan still validate, with and without cidr. hostname("[fe80::1%eth0]:22") is unchanged.
  • email() rejects a % in the domain part. A domain name can't contain one anyway, so only zone IDs in address literals are affected. The check sits right after the split, so it doesn't overlap Accept tagged IPv6 email address literals #526 or Require exactly one pair of brackets around IP literals #535.

url() isn't affected: it already rejects raw control characters and whitespace anywhere in the URL before parsing it.

Tests

The 27 new rejection cases in test_ip_address.py, test_hostname.py and test_email.py fail on master and pass here, and the 14 positive cases pass on both. The full suite (1615 tests), ruff check, ruff format, pyright and bandit pass. A fuzz comparison against master covered ipv6() (with and without cidr), hostname() (bare and bracketed with a port), url() and email(), over 40,000 mutated addresses. It found nothing newly accepted and no change for url(). Every newly rejected value either has a control or whitespace character in its zone ID, or is an email with a % in its domain part.

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.

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

Development

Successfully merging this pull request may close these issues.

1 participant