From c5796d79d44c76a54f03e8938dd22edaa1221c27 Mon Sep 17 00:00:00 2001 From: Kim Davies Date: Mon, 7 Sep 2026 06:40:21 -0700 Subject: [PATCH] Skip the decode round-trip check for domains past encode's length limit decode() deliberately allows the trailing-dot octet whether or not one is present, since UTS #46 ToUnicode checks no lengths, so a 254-octet domain without a trailing dot decodes but is rejected by encode(). Fixes OSS-Fuzz 4694619275984896. --- tests/fuzz_idna_api.py | 10 ++++++---- tests/test_idna.py | 12 ++++++++++++ tests/test_idna_fuzz_targets.py | 1 + 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/fuzz_idna_api.py b/tests/fuzz_idna_api.py index a694ac4..8daf9e7 100755 --- a/tests/fuzz_idna_api.py +++ b/tests/fuzz_idna_api.py @@ -62,14 +62,16 @@ def fuzz_decode(fdp): return # RFC 5891 ยง5.3: an A-label that decodes must re-encode to itself, so # under strict non-UTS46 processing any ASCII input that decodes is (up - # to case) its own encoding. decode() does not enforce the 63-octet - # label limit (nor does UTS #46 ToUnicode) but encode() does, so skip - # overlong labels. + # to case) its own encoding. decode()'s length checks are deliberately + # lenient (no 63-octet label limit, and the whole-domain limit allows + # the trailing-dot octet whether or not one is present, as UTS #46 + # ToUnicode checks no lengths at all) while encode() enforces both + # exactly, so skip inputs past either limit. if isinstance(data, str): if not data.isascii(): return data = data.encode("ascii") - if all(len(label) <= 63 for label in data.split(b".")): + if idna.valid_string_length(data, data.endswith(b".")) and all(len(label) <= 63 for label in data.split(b".")): assert idna.encode(decoded, strict=True) == data.lower(), (data, decoded) diff --git a/tests/test_idna.py b/tests/test_idna.py index 69c85ac..752b032 100644 --- a/tests/test_idna.py +++ b/tests/test_idna.py @@ -79,6 +79,18 @@ def test_valid_label_length(self): self.assertFalse(idna.valid_label_length("a" * 64)) self.assertRaises(idna.IDNAError, idna.encode, "a" * 64) + def test_decode_domain_length_leniency(self): + # UTS #46 ToUnicode checks no lengths, so decode()'s whole-domain + # bound is a lenient pre-filter that always allows the trailing-dot + # octet, while encode() enforces the exact RFC 1035 limit: a + # 254-octet domain without a trailing dot decodes but does not + # re-encode (OSS-Fuzz 4694619275984896), and with one it round-trips. + domain = b"aaa." * 63 + b"aa" # 254 octets, every label within limits + decoded = idna.decode(domain, strict=True) + self.assertRaises(idna.IDNAError, idna.encode, decoded, strict=True) + dotted = domain[:-1] + b"." + self.assertEqual(idna.encode(idna.decode(dotted, strict=True), strict=True), dotted) + def test_oversized_input_rejected_promptly(self): # GHSA-65pc-fj4g-8rjx: encode/decode must reject inputs that # exceed the maximum DNS domain length before per-codepoint diff --git a/tests/test_idna_fuzz_targets.py b/tests/test_idna_fuzz_targets.py index fa05f55..edf81a6 100644 --- a/tests/test_idna_fuzz_targets.py +++ b/tests/test_idna_fuzz_targets.py @@ -43,6 +43,7 @@ b"xn---bbk.example", # non-canonical spelling of xn--bbk b"XN--MNCHEN-3YA.example", b"a." * 200, # every label valid, but past the 253-octet domain limit + b"aaa." * 63 + b"aa", # 254 octets, no trailing dot: decodes, too long to encode ("\ufdfa" * 100).encode(), # UTS #46 maps each to 18 characters, past the input cap ]