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.