diff --git a/pkgs/http_parser/CHANGELOG.md b/pkgs/http_parser/CHANGELOG.md index 6036418978..ff55740eff 100644 --- a/pkgs/http_parser/CHANGELOG.md +++ b/pkgs/http_parser/CHANGELOG.md @@ -4,6 +4,10 @@ Previously, two-digit years were always interpreted as years between 1900 and 1999. * Replace reference to `dart:web` with `package:web` in README.md. +* Fixed a bug where `chunkedCoding` failed to throw a `FormatException` when + decoding a chunk size that overflows a 64-bit integer. +* Fixed `MediaType.toString()` to escape backslashes in quoted parameter values + and quote empty parameter values. ## 4.1.2 diff --git a/pkgs/http_parser/lib/src/chunked_coding/decoder.dart b/pkgs/http_parser/lib/src/chunked_coding/decoder.dart index 9eb8e93b4c..5ffeae5d6e 100644 --- a/pkgs/http_parser/lib/src/chunked_coding/decoder.dart +++ b/pkgs/http_parser/lib/src/chunked_coding/decoder.dart @@ -14,6 +14,16 @@ import 'charcodes.dart'; /// The canonical instance of [ChunkedCodingDecoder]. const chunkedCodingDecoder = ChunkedCodingDecoder._(); +/// The maximum value of [_Sink._size] before multiplying by 16 and adding a hex +/// digit (`0..15`). +/// +/// On the Dart VM and dart2wasm (64-bit signed `int`), this is +/// `0x7fffffffffffffff ~/ 16` (`0x07ffffffffffffff`). On JavaScript (`53`-bit +/// safe integers), this is `0x1fffffffffffff ~/ 16` (`0x01ffffffffffff`). +const _maxSizeBeforeShift = identical(1.0, 1) + ? 0x01ffffffffffff + : (0x07ffffff * 0x100000000) + 0xffffffff; + /// A converter that decodes byte arrays into chunks with size tags. class ChunkedCodingDecoder extends Converter, List> { const ChunkedCodingDecoder._(); @@ -95,7 +105,11 @@ class _Sink extends ByteConversionSinkBase { } else { // Shift four bits left since a single hex digit contains four bits // of information. - _size = (_size << 4) + _digitForByte(bytes, start); + final digit = _digitForByte(bytes, start); + if (_size > _maxSizeBeforeShift) { + throw FormatException('Chunk size is too large.', bytes, start); + } + _size = (_size * 16) + digit; } start++; @@ -105,9 +119,10 @@ class _Sink extends ByteConversionSinkBase { start++; case _State.body: - final chunkEnd = math.min(end, start + _size); + final bytesToRead = math.min(end - start, _size); + final chunkEnd = start + bytesToRead; buffer.addAll(bytes, start, chunkEnd); - _size -= chunkEnd - start; + _size -= bytesToRead; start = chunkEnd; if (_size == 0) _state = _State.bodyBeforeCR; @@ -178,58 +193,51 @@ enum _State { /// next chunk. /// /// Transitions to [size]. - boundary('boundary'), + boundary, /// The parser has parsed at least one digit of the chunk size header, but has /// not yet parsed the `CR LF` sequence that indicates the end of that header. /// /// Transitions to [sizeBeforeLF]. - size('size'), + size, /// The parser has parsed the chunk size header and the CR character after it, /// but not the LF. /// /// Transitions to [body] or [bodyBeforeCR]. - sizeBeforeLF('size before LF'), + sizeBeforeLF, /// The parser has parsed a chunk header and possibly some of the body, but /// still needs to consume more bytes. /// /// Transitions to [bodyBeforeCR]. - body('body'), + body, // The parser has parsed all the bytes in a chunk body but not the CR LF // sequence that follows it. // // Transitions to [bodyBeforeLF]. - bodyBeforeCR('body before CR'), + bodyBeforeCR, // The parser has parsed all the bytes in a chunk body and the CR that follows // it, but not the LF after that. // // Transitions to [boundary]. - bodyBeforeLF('body before LF'), + bodyBeforeLF, /// The parser has parsed the final empty chunk but not the CR LF sequence /// that follows it. /// /// Transitions to [endBeforeLF]. - endBeforeCR('end before CR'), + endBeforeCR, /// The parser has parsed the final empty chunk and the CR that follows it, /// but not the LF after that. /// /// Transitions to [end]. - endBeforeLF('end before LF'), + endBeforeLF, /// The parser has parsed the final empty chunk as well as the CR LF that /// follows, and expects no more data. - end('end'); - - const _State(this.name); - - final String name; - - @override - String toString() => name; + end, } diff --git a/pkgs/http_parser/lib/src/media_type.dart b/pkgs/http_parser/lib/src/media_type.dart index 814de63467..3b88da7d44 100644 --- a/pkgs/http_parser/lib/src/media_type.dart +++ b/pkgs/http_parser/lib/src/media_type.dart @@ -11,7 +11,7 @@ import 'utils.dart'; /// A regular expression matching a character that needs to be backslash-escaped /// in a quoted string. -final _escapedChar = RegExp(r'["\x00-\x1F\x7F]'); +final _escapedChar = RegExp(r'["\x00-\x1F\x7F\\]'); /// A class representing an HTTP media type, as used in Accept and Content-Type /// headers. @@ -138,7 +138,7 @@ class MediaType { parameters.forEach((attribute, value) { buffer.write('; $attribute='); - if (nonToken.hasMatch(value)) { + if (value.isEmpty || nonToken.hasMatch(value)) { buffer ..write('"') ..write( diff --git a/pkgs/http_parser/test/chunked_coding_test.dart b/pkgs/http_parser/test/chunked_coding_test.dart index fc4c13ff39..0c23216a39 100644 --- a/pkgs/http_parser/test/chunked_coding_test.dart +++ b/pkgs/http_parser/test/chunked_coding_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:http_parser/http_parser.dart'; import 'package:http_parser/src/chunked_coding/charcodes.dart'; @@ -193,6 +194,13 @@ void main() { expect(chunkedCoding.decode([$0, $cr, $lf, $cr, $lf]), isEmpty); }); + test('parses hex size with leading zeros', () { + expect( + chunkedCoding + .decode(ascii.encode('00000000000000000003\r\nabc\r\n0\r\n\r\n')), + equals(ascii.encode('abc'))); + }); + group('disallows a message', () { test('that ends without any input', () { expect(() => chunkedCoding.decode([]), throwsFormatException); @@ -216,6 +224,14 @@ void main() { throwsFormatException); }); + test('that ends after insufficient bytes for max chunk size', () { + const maxChunkSizeHex = + identical(1.0, 1) ? '1fffffffffffff' : '7fffffffffffffff'; + expect( + () => chunkedCoding.decode(ascii.encode('$maxChunkSizeHex\r\na')), + throwsFormatException); + }); + test("that ends after a chunk's bytes", () { expect(() => chunkedCoding.decode([$1, $cr, $lf, 1]), throwsFormatException); @@ -250,6 +266,29 @@ void main() { expect( () => chunkedCoding.decode([$q, $cr, $lf, $0, $cr, $lf, $cr, $lf]), throwsFormatException); + expect( + () => chunkedCoding.decode([-1, $cr, $lf, $0, $cr, $lf, $cr, $lf]), + throwsFormatException); + }); + + test('with a chunk size that overflows 64-bit int', () { + expect( + () => chunkedCoding.decode(ascii.encode('8000000000000000\r\na')), + throwsFormatException); + expect( + () => chunkedCoding.decode(ascii.encode('ffffffffffffffff\r\na')), + throwsFormatException); + }); + + test('with a chunk size exceeding 16 hex digits', () { + expect( + () => + chunkedCoding.decode(ascii.encode('10000000000000000\r\n\r\n')), + throwsFormatException); + expect( + () => chunkedCoding + .decode(ascii.encode('10000000000000003\r\nabc\r\n0\r\n\r\n')), + throwsFormatException); }); }); @@ -517,6 +556,66 @@ void main() { }); }); }); + + test('rejects chunk sizes that overflow 64-bit int', () { + expect(() => sink.add(ascii.encode('8000000000000000\r\na')), + throwsFormatException); + }); + + test('rejects chunk sizes that overflow 64-bit int across chunks', () { + sink.add(ascii.encode('80000000')); + expect(() => sink.add(ascii.encode('00000000\r\na')), + throwsFormatException); + }); + + test('rejects chunk sizes exceeding 16 hex digits across chunks', () { + sink.add(ascii.encode('10000000')); + expect(() => sink.add(ascii.encode('000000000\r\n\r\n')), + throwsFormatException); + }); + + test('handles max chunk size without start + size overflow', () { + const maxChunkSizeHex = + identical(1.0, 1) ? '1fffffffffffff' : '7fffffffffffffff'; + sink.add(ascii.encode('$maxChunkSizeHex\r\na')); + expect( + results, + equals([ + [$a] + ])); + expect(() => sink.close(), throwsFormatException); + }); + + test('matches single-chunk decode across random chunk splits', () { + final rng = Random(12345); + for (var iter = 0; iter < 200; iter++) { + late final List fullPayload; + final encodeSink = chunkedCoding.encoder.startChunkedConversion( + ByteConversionSink.withCallback((bytes) => fullPayload = bytes), + ); + final numChunks = rng.nextInt(5) + 1; + for (var c = 0; c < numChunks; c++) { + final len = rng.nextInt(40); + encodeSink.add(List.generate(len, (_) => rng.nextInt(256))); + } + encodeSink.close(); + + final expected = chunkedCoding.decode(fullPayload); + late final List actual; + final decodeSink = chunkedCoding.decoder.startChunkedConversion( + ByteConversionSink.withCallback((bytes) => actual = bytes), + ); + var offset = 0; + while (offset < fullPayload.length) { + final step = rng.nextInt(7); + final end = min(offset + step, fullPayload.length); + decodeSink.addSlice(fullPayload, offset, end, false); + offset = end; + } + decodeSink.close(); + expect(actual, equals(expected)); + } + }); }); }); } diff --git a/pkgs/http_parser/test/media_type_test.dart b/pkgs/http_parser/test/media_type_test.dart index 9a4226c67b..19474939e5 100644 --- a/pkgs/http_parser/test/media_type_test.dart +++ b/pkgs/http_parser/test/media_type_test.dart @@ -151,11 +151,29 @@ void main() { equals('text/plain; foo="bar baz"')); }); + test('serializes an empty parameter as a quoted string', () { + final type = MediaType('text', 'plain', {'foo': ''}); + expect(type.toString(), equals('text/plain; foo=""')); + expect(MediaType.parse(type.toString()).parameters, equals({'foo': ''})); + }); + test('escapes a quoted string as necessary', () { expect(MediaType('text', 'plain', {'foo': 'bar"\x7Fbaz'}).toString(), equals('text/plain; foo="bar\\"\\\x7Fbaz"')); }); + test('escapes backslashes in a quoted string and round-trips', () { + final type = MediaType.parse( + r'text/html; charset=utf-8; foo="bar\\baz"; trail="end\\"', + ); + expect( + type.toString(), + equals(r'text/html; charset=utf-8; foo="bar\\baz"; trail="end\\"'), + ); + final reparsed = MediaType.parse(type.toString()); + expect(reparsed.parameters, equals(type.parameters)); + }); + test('serializes multiple parameters', () { expect( MediaType('text', 'plain', {'foo': 'bar', 'baz': 'bang'}).toString(),