Skip to content

Require exactly one pair of brackets around IP literals - #535

Open
nandgator wants to merge 1 commit into
masterfrom
fix-bracketed-host-literals
Open

nandgator wants to merge 1 commit into
masterfrom
fix-bracketed-host-literals

Conversation

@nandgator

@nandgator nandgator commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

hostname() strips every leading [ and trailing ] from a host followed by a port, then validates what's left as a simple hostname, domain, IPv4 or IPv6 address. As a result, all of these validate on master:

>>> validators.hostname("[1.2.3.4]:80")
True
>>> validators.hostname("[example.com]:80")
True
>>> validators.hostname("[[::1]]:80"), validators.hostname("::1]:80")
(True, True)

RFC 3986 section 3.2.2 only encloses an IPv6 (or IPvFuture) address in brackets. url() already rejects these, but hostname() is often used on host:port values directly.

email() has the same flaw for address literals: with ipv4_address or ipv6_address set, user@[[192.0.2.1]], user@[192.0.2.1]] and user@[[[2001:db8::1]]] validate.

Changes

  • In hostname(), a bracketed host before the port must be one pair of brackets around an IPv6 address. It's never tried as a hostname, domain or IPv4 address, and it's rejected when skip_ipv6_addr is set.
  • email() strips exactly one pair of brackets from an address literal.
  • In both, any further bracket inside the pair is rejected. ipv6() delegates to ipaddress, which accepts almost any zone ID, including ] (::1%]). Without this check, stripping exactly one pair would newly accept user@[2001:db8::1%]].

Tests

Of the 20 new cases in test_hostname.py and test_email.py, 14 fail on master and all pass here. The other 6 already pass on master: positive cases for single-bracket IPv4 and IPv6 email literals, which had no tests, and 4 guards. Two of the guards, [::1%]]:80 and user@[2001:db8::1%]], would have been newly accepted by stripping exactly one pair without the inner-bracket check. The full suite (1594 tests), ruff check, ruff format, pyright and bandit pass.

A fuzz comparison against master covered hostname() (with and without skip_ipv6_addr), url() and email() address literals, over 30,000 mutated hosts. It found nothing newly accepted and no change for url(). Every newly rejected value has brackets that don't enclose exactly one valid IPv6 address.

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%]").

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