Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkgs/http_parser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 27 additions & 19 deletions pkgs/http_parser/lib/src/chunked_coding/decoder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>, List<int>> {
const ChunkedCodingDecoder._();
Expand Down Expand Up @@ -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++;

Expand All @@ -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;

Expand Down Expand Up @@ -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,
}
4 changes: 2 additions & 2 deletions pkgs/http_parser/lib/src/media_type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
99 changes: 99 additions & 0 deletions pkgs/http_parser/test/chunked_coding_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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<int> 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<int>.generate(len, (_) => rng.nextInt(256)));
}
encodeSink.close();

final expected = chunkedCoding.decode(fullPayload);
late final List<int> 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));
}
});
});
});
}
18 changes: 18 additions & 0 deletions pkgs/http_parser/test/media_type_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading