From fddcab05260e67c80906760f6cdc6bac1cdac0eb Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 22 Aug 2026 14:59:04 -0400 Subject: [PATCH] [Idn] Bound the Punycode decoder input length punycodeDecode() inserts every decoded character into an array with array_splice(), making the decode quadratic in the payload length. The resulting label size is only checked after decoding, so a crafted multi-kilobyte "xn--" label reaching idn_to_utf8()/idn_to_ascii() spends minutes of CPU before being rejected: 5 KB took 36 ms, 80 KB took 12 s and 200 KB took 177 s on a single call. Valid ACE labels are limited to 63 bytes, so payloads beyond a generous 1024-byte bound are always invalid input. Reject them up front with ERROR_PUNYCODE without invoking the decoder, capping the worst case at about half a million elementary operations (~milliseconds) per label. This mirrors ext-intl, which never decodes absurdly long labels: native ICU rejects a 5 KB domain instantly. Labels above the DNS limit but below this bound still decode exactly as before (ToUnicode reports their decoded form even though they are invalid), keeping the UTS #46 spec fixtures passing. --- src/Intl/Idn/Idn.php | 17 +++++++++++++++++ tests/Intl/Idn/IdnTest.php | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/Intl/Idn/Idn.php b/src/Intl/Idn/Idn.php index ca9990d8..8bf081bb 100644 --- a/src/Intl/Idn/Idn.php +++ b/src/Intl/Idn/Idn.php @@ -61,6 +61,13 @@ final class Idn public const DELIMITER = '-'; public const MAX_INT = 2147483647; + /** + * Punycode decoding does work quadratic in the payload length. Valid ACE + * labels are limited to 63 bytes, so payloads beyond this (generous) bound + * are rejected without being decoded to keep the work bounded. + */ + private const MAX_DECODE_PAYLOAD_SIZE = 1024; + /** * Contains the numeric value of a basic code point (for use in representing integers) in the * range 0 to BASE-1, or -1 if b is does not represent a value. @@ -358,6 +365,16 @@ private static function process($domain, array $options, Info $info) continue; } + // The decoder does work quadratic in the payload length. Valid + // labels are at most 63 bytes long, so a payload beyond this + // bound is always invalid input: reject it without decoding, + // like ext-intl does, to avoid spending unbounded time on it. + if (\strlen($label) - 4 > self::MAX_DECODE_PAYLOAD_SIZE) { + $info->errors |= self::ERROR_PUNYCODE; + + continue; + } + // Step 4.2. Attempt to convert the rest of the label to Unicode according to Punycode [RFC3492]. If // that conversion fails, record that there was an error, and continue // with the next label. Otherwise replace the original label in the string by the results of the diff --git a/tests/Intl/Idn/IdnTest.php b/tests/Intl/Idn/IdnTest.php index 4c07b2e6..195c6128 100644 --- a/tests/Intl/Idn/IdnTest.php +++ b/tests/Intl/Idn/IdnTest.php @@ -351,6 +351,45 @@ public function testLabelStartingWithPunycodePrefixWithNonAsciiCharacterRecordsE $this->assertSame('xn--🌈.faß.de', $info['result']); } + /** + * Punycode decoding does work quadratic in the payload length. Valid ACE + * labels are limited to 63 bytes, so a multi-kilobyte payload is always + * invalid input and must be rejected without being decoded (like ext-intl, + * which never decodes absurdly long labels). + */ + public function testHugeAcePayloadIsRejectedWithoutDecoding() + { + $input = 'example.xn--'.str_repeat('w', 5000).'.com'; + + $info = []; + $r = idn_to_utf8($input, \IDNA_DEFAULT, \INTL_IDNA_VARIANT_UTS46, $info); + $this->assertFalse($r); + // ext-intl does not populate $info when it bails out early. + if ([] !== $info) { + $this->assertSame(\IDNA_ERROR_PUNYCODE, \IDNA_ERROR_PUNYCODE & $info['errors']); + } + + $info = []; + $r = idn_to_ascii($input, \IDNA_DEFAULT, \INTL_IDNA_VARIANT_UTS46, $info); + $this->assertFalse($r); + if ([] !== $info) { + $this->assertSame(\IDNA_ERROR_PUNYCODE, \IDNA_ERROR_PUNYCODE & $info['errors']); + } + } + + /** + * Labels above the DNS limit but below the decoder bound still decode: + * ToUnicode reports their decoded form even though they are invalid. + */ + public function testOverLongButDecodableAceLabelStillDecodes() + { + $input = 'example.xn--'.str_repeat('w', 101).'.de'; + + idn_to_utf8($input, \IDNA_DEFAULT, \INTL_IDNA_VARIANT_UTS46, $info); + $this->assertStringContainsString('example.', $info['result']); + $this->assertStringNotContainsString('xn--', $info['result']); + } + /** * UTS #46 revision 33: a label starting with "xn--" whose Punycode payload decodes * to an empty string or to a string containing only ASCII code points must be rejected.