From ddc534b4509ce820b00bb350e2c8e50b2c429052 Mon Sep 17 00:00:00 2001 From: guettlibot Date: Sat, 11 Jul 2026 18:06:36 +0000 Subject: [PATCH] fix: guard QuotedPrintable decode against truncated =XY escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodeText read cleaned.substring(i + 1, i + 3) unconditionally whenever it saw '='. Two paths could take i + 3 past cleaned.length and throw a RangeError: * the outer branch, when the input ended with a bare '=' or '=X' * the inner while loop, whose guard cleaned.length > (i + 4) proves cleaned[i + 3] exists but not that the substring(i+4, i+6) after i += 3 is in bounds RFC 2045 §6.7 permits consumers to preserve malformed input verbatim, which matches how the existing int.tryParse == null branch already handles non-hex '=XY'. Extend that to both truncated cases: preserve the tail verbatim on the outer branch and bail out of the inner loop so the outer branch can pick up the leftover characters. Reported from downstream in guettli/sharedinbox#232, where an incoming mail body whose quoted-printable payload ended mid-escape crashed the entire app via runZonedGuarded. --- .../codecs/quoted_printable_mail_codec.dart | 13 ++++++++ .../quoted_printable_mail_codec_test.dart | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/src/codecs/quoted_printable_mail_codec.dart b/lib/src/codecs/quoted_printable_mail_codec.dart index d3e8c6e7..27d83629 100644 --- a/lib/src/codecs/quoted_printable_mail_codec.dart +++ b/lib/src/codecs/quoted_printable_mail_codec.dart @@ -197,6 +197,14 @@ class QuotedPrintableMailCodec extends MailCodec { for (var i = 0; i < cleaned.length; i++) { final char = cleaned[i]; if (char == '=') { + // A '=' within two characters of the end has no complete hex escape + // following it. RFC 2045 §6.7 permits consumers to preserve such + // malformed input verbatim; the alternative here is a RangeError + // from substring on the two lines below. + if (i + 3 > cleaned.length) { + buffer.write(cleaned.substring(i)); + break; + } final hexText = cleaned.substring(i + 1, i + 3); var charCode = int.tryParse(hexText, radix: 16); if (charCode == null) { @@ -208,6 +216,11 @@ class QuotedPrintableMailCodec extends MailCodec { } else { final charCodes = [charCode]; while (cleaned.length > (i + 4) && cleaned[i + 3] == '=') { + // The while check only proves cleaned[i+3] exists; the substring + // below still reads cleaned[i+4..i+5], so stop if the trailing + // '=' has fewer than two hex chars after it and let the outer + // loop pick it up via the truncated-tail branch above. + if (i + 6 > cleaned.length) break; i += 3; final hexText = cleaned.substring(i + 1, i + 3); charCode = int.parse(hexText, radix: 16); diff --git a/test/codecs/quoted_printable_mail_codec_test.dart b/test/codecs/quoted_printable_mail_codec_test.dart index bac1c6fd..d8da4b92 100644 --- a/test/codecs/quoted_printable_mail_codec_test.dart +++ b/test/codecs/quoted_printable_mail_codec_test.dart @@ -74,6 +74,37 @@ void main() { 'jeden Tag ändern können', ); }); + + group('malformed truncated =XY escapes are preserved verbatim', () { + // Regression: a '=' within two characters of the end used to throw + // RangeError from substring(i+1, i+3). Per RFC 2045 §6.7 consumers may + // preserve the malformed tail; matches the existing behaviour for + // non-hex '=XY' where the raw text is written to the buffer. + test('trailing bare "="', () { + expect( + MailCodec.quotedPrintable.decodeText('hello=', convert.utf8), + 'hello=', + ); + }); + test('trailing "=A"', () { + expect( + MailCodec.quotedPrintable.decodeText('hello=A', convert.utf8), + 'hello=A', + ); + }); + test('lone "=A" at end of a short input', () { + expect( + MailCodec.quotedPrintable.decodeText('h=A', convert.utf8), + 'h=A', + ); + }); + test('valid multi-byte escape followed by truncated "=A"', () { + expect( + MailCodec.quotedPrintable.decodeText('hi=C3=B6=A', convert.utf8), + 'hiö=A', + ); + }); + }); }); group('Quoted Printable encoding', () {