Skip to content

Commit 1a42b4a

Browse files
authored
Harmonize response header behavior (dart-lang#993)
1 parent db276f8 commit 1a42b4a

File tree

12 files changed

+168
-40
lines changed

12 files changed

+168
-40
lines changed

pkgs/cronet_http/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## 0.2.2
22

33
* Require Dart 3.0
4+
* Throw `ClientException` when the `'Content-Length'` header is invalid.
45

56
## 0.2.1
67

pkgs/cronet_http/lib/src/cronet_client.dart

+14-4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import 'package:http/http.dart';
2121
import 'messages.dart' as messages;
2222

2323
final _api = messages.HttpApi();
24+
final _digitRegex = RegExp(r'^\d+$');
2425

2526
final Finalizer<String> _cronetEngineFinalizer = Finalizer(_api.freeEngine);
2627

@@ -266,11 +267,20 @@ class CronetClient extends BaseClient {
266267
.cast<String, List<Object?>>()
267268
.map((key, value) => MapEntry(key.toLowerCase(), value.join(',')));
268269

269-
final contentLengthHeader = responseHeaders['content-length'];
270+
int? contentLength;
271+
switch (responseHeaders['content-length']) {
272+
case final contentLengthHeader?
273+
when !_digitRegex.hasMatch(contentLengthHeader):
274+
throw ClientException(
275+
'Invalid content-length header [$contentLengthHeader].',
276+
request.url,
277+
);
278+
case final contentLengthHeader?:
279+
contentLength = int.parse(contentLengthHeader);
280+
}
281+
270282
return StreamedResponse(responseDataController.stream, result.statusCode,
271-
contentLength: contentLengthHeader == null
272-
? null
273-
: int.tryParse(contentLengthHeader),
283+
contentLength: contentLength,
274284
reasonPhrase: result.statusText,
275285
request: request,
276286
isRedirect: result.isRedirect,

pkgs/cronet_http/pubspec.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: cronet_http
22
description: >
33
An Android Flutter plugin that provides access to the Cronet HTTP client.
4-
version: 0.2.1
4+
version: 0.2.2
55
repository: https://github.com/dart-lang/http/tree/master/pkgs/cronet_http
66

77
environment:

pkgs/cupertino_http/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
any `List<int>`.
99
* Disable additional analyses for generated Objective-C bindings to prevent
1010
errors from `dart analyze`.
11+
* Throw `ClientException` when the `'Content-Length'` header is invalid.
1112

1213
## 1.0.1
1314

pkgs/cupertino_http/lib/src/cupertino_client.dart

+14-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import 'package:http/http.dart';
1515

1616
import 'cupertino_api.dart';
1717

18+
final _digitRegex = RegExp(r'^\d+$');
19+
1820
class _TaskTracker {
1921
final responseCompleter = Completer<URLResponse>();
2022
final BaseRequest request;
@@ -279,6 +281,17 @@ class CupertinoClient extends BaseClient {
279281
throw ClientException('Redirect limit exceeded', request.url);
280282
}
281283

284+
final responseHeaders = response.allHeaderFields
285+
.map((key, value) => MapEntry(key.toLowerCase(), value));
286+
287+
if (responseHeaders['content-length'] case final contentLengthHeader?
288+
when !_digitRegex.hasMatch(contentLengthHeader)) {
289+
throw ClientException(
290+
'Invalid content-length header [$contentLengthHeader].',
291+
request.url,
292+
);
293+
}
294+
282295
return StreamedResponse(
283296
taskTracker.responseController.stream,
284297
response.statusCode,
@@ -288,8 +301,7 @@ class CupertinoClient extends BaseClient {
288301
reasonPhrase: _findReasonPhrase(response.statusCode),
289302
request: request,
290303
isRedirect: !request.followRedirects && taskTracker.numRedirects > 0,
291-
headers: response.allHeaderFields
292-
.map((key, value) => MapEntry(key.toLowerCase(), value)),
304+
headers: responseHeaders,
293305
);
294306
}
295307
}

pkgs/http/CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 1.1.1
2+
3+
* `BrowserClient` throws `ClientException` when the `'Content-Length'` header
4+
is invalid.
5+
* `IOClient` trims trailing whitespace on header values.
6+
17
## 1.1.0
28

39
* Add better error messages for `SocketException`s when using `IOClient`.

pkgs/http/lib/src/browser_client.dart

+10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import 'byte_stream.dart';
1212
import 'exception.dart';
1313
import 'streamed_response.dart';
1414

15+
final _digitRegex = RegExp(r'^\d+$');
16+
1517
/// Create a [BrowserClient].
1618
///
1719
/// Used from conditional imports, matches the definition in `client_stub.dart`.
@@ -64,6 +66,14 @@ class BrowserClient extends BaseClient {
6466
var completer = Completer<StreamedResponse>();
6567

6668
unawaited(xhr.onLoad.first.then((_) {
69+
if (xhr.responseHeaders['content-length'] case final contentLengthHeader?
70+
when !_digitRegex.hasMatch(contentLengthHeader)) {
71+
completer.completeError(ClientException(
72+
'Invalid content-length header [$contentLengthHeader].',
73+
request.url,
74+
));
75+
return;
76+
}
6777
var body = (xhr.response as ByteBuffer).asUint8List();
6878
completer.complete(StreamedResponse(
6979
ByteStream.fromBytes(body), xhr.status!,

pkgs/http/lib/src/io_client.dart

+4-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ class IOClient extends BaseClient {
9898

9999
var headers = <String, String>{};
100100
response.headers.forEach((key, values) {
101-
headers[key] = values.join(',');
101+
// TODO: Remove trimRight() when
102+
// https://github.com/dart-lang/sdk/issues/53005 is resolved and the
103+
// package:http SDK constraint requires that version or later.
104+
headers[key] = values.map((value) => value.trimRight()).join(',');
102105
});
103106

104107
return IOStreamedResponse(

pkgs/http/pubspec.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: http
2-
version: 1.1.0
2+
version: 1.1.1-wip
33
description: A composable, multi-platform, Future-based API for HTTP requests.
44
repository: https://github.com/dart-lang/http/tree/master/pkgs/http
55

pkgs/http_client_conformance_tests/lib/src/response_headers_server.dart

+14-7
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,21 @@ void hybridMain(StreamChannel<Object?> channel) async {
2222

2323
server = (await HttpServer.bind('localhost', 0))
2424
..listen((request) async {
25-
request.response.headers.set('Access-Control-Allow-Origin', '*');
26-
request.response.headers.set('Access-Control-Expose-Headers', '*');
25+
await request.drain<void>();
26+
final socket = await request.response.detachSocket(writeHeaders: false);
2727

28-
(await clientQueue.next as Map).forEach((key, value) => request
29-
.response.headers
30-
.set(key as String, value as String, preserveHeaderCase: true));
31-
32-
await request.response.close();
28+
final headers = (await clientQueue.next) as String;
29+
socket
30+
..writeAll([
31+
'HTTP/1.1 200 OK',
32+
'Access-Control-Allow-Origin: *',
33+
'Access-Control-Expose-Headers: *',
34+
'Content-Type: text/plain',
35+
'', // Add \r\n at the end of this header section.
36+
], '\r\n')
37+
..write(headers)
38+
..write('Connection: Closed\r\n\r\n');
39+
await socket.close();
3340
unawaited(server.close());
3441
});
3542

pkgs/http_client_conformance_tests/lib/src/response_headers_tests.dart

+81-6
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,23 @@ void testResponseHeaders(Client client) async {
2424
});
2525

2626
test('single header', () async {
27-
httpServerChannel.sink.add({'foo': 'bar'});
27+
httpServerChannel.sink.add('foo: bar\r\n');
2828

2929
final response = await client.get(Uri.http(host, ''));
3030
expect(response.headers['foo'], 'bar');
3131
});
3232

33-
test('UPPERCASE header', () async {
34-
httpServerChannel.sink.add({'foo': 'BAR'});
33+
test('UPPERCASE header value', () async {
34+
httpServerChannel.sink.add('foo: BAR\r\n');
35+
36+
final response = await client.get(Uri.http(host, ''));
37+
// RFC 2616 14.44 states that header field names are case-insensitive.
38+
// http.Client canonicalizes field names into lower case.
39+
expect(response.headers['foo'], 'BAR');
40+
});
41+
42+
test('space surrounding header value', () async {
43+
httpServerChannel.sink.add('foo: \t BAR \t \r\n');
3544

3645
final response = await client.get(Uri.http(host, ''));
3746
// RFC 2616 14.44 states that header field names are case-insensitive.
@@ -41,7 +50,7 @@ void testResponseHeaders(Client client) async {
4150

4251
test('multiple headers', () async {
4352
httpServerChannel.sink
44-
.add({'field1': 'value1', 'field2': 'value2', 'field3': 'value3'});
53+
.add('field1: value1\r\n' 'field2: value2\r\n' 'field3: value3\r\n');
4554

4655
final response = await client.get(Uri.http(host, ''));
4756
expect(response.headers['field1'], 'value1');
@@ -50,10 +59,76 @@ void testResponseHeaders(Client client) async {
5059
});
5160

5261
test('multiple values per header', () async {
53-
httpServerChannel.sink.add({'list': 'apple, orange, banana'});
62+
// RFC-2616 4.2 says:
63+
// "The field value MAY be preceded by any amount of LWS, though a single
64+
// SP is preferred." and
65+
// "The field-content does not include any leading or trailing LWS ..."
66+
httpServerChannel.sink.add('list: apple, orange, banana\r\n');
5467

5568
final response = await client.get(Uri.http(host, ''));
56-
expect(response.headers['list'], 'apple, orange, banana');
69+
expect(response.headers['list'],
70+
matches(r'apple[ \t]*,[ \t]*orange[ \t]*,[ \t]*banana'));
71+
});
72+
73+
test('multiple values per header surrounded with spaces', () async {
74+
httpServerChannel.sink
75+
.add('list: \t apple \t, \t orange \t , \t banana\t \t \r\n');
76+
77+
final response = await client.get(Uri.http(host, ''));
78+
expect(response.headers['list'],
79+
matches(r'apple[ \t]*,[ \t]*orange[ \t]*,[ \t]*banana'));
80+
});
81+
82+
test('multiple headers with the same name', () async {
83+
httpServerChannel.sink.add('list: apple\r\n'
84+
'list: orange\r\n'
85+
'list: banana\r\n');
86+
87+
final response = await client.get(Uri.http(host, ''));
88+
expect(response.headers['list'],
89+
matches(r'apple[ \t]*,[ \t]*orange[ \t]*,[ \t]*banana'));
90+
});
91+
92+
test('multiple headers with the same name but different cases', () async {
93+
httpServerChannel.sink.add('list: apple\r\n'
94+
'LIST: orange\r\n'
95+
'List: banana\r\n');
96+
97+
final response = await client.get(Uri.http(host, ''));
98+
expect(response.headers['list'],
99+
matches(r'apple[ \t]*,[ \t]*orange[ \t]*,[ \t]*banana'));
100+
});
101+
102+
group('content length', () {
103+
test('surrounded in spaces', () async {
104+
// RFC-2616 4.2 says:
105+
// "The field value MAY be preceded by any amount of LWS, though a
106+
// single SP is preferred." and
107+
// "The field-content does not include any leading or trailing LWS ..."
108+
httpServerChannel.sink.add('content-length: \t 0 \t \r\n');
109+
final response = await client.get(Uri.http(host, ''));
110+
expect(response.contentLength, 0);
111+
},
112+
skip: 'Enable after https://github.com/dart-lang/sdk/issues/51532 '
113+
'is fixed');
114+
115+
test('non-integer', () async {
116+
httpServerChannel.sink.add('content-length: cat\r\n');
117+
await expectLater(
118+
client.get(Uri.http(host, '')), throwsA(isA<ClientException>()));
119+
});
120+
121+
test('negative', () async {
122+
httpServerChannel.sink.add('content-length: -5\r\n');
123+
await expectLater(
124+
client.get(Uri.http(host, '')), throwsA(isA<ClientException>()));
125+
});
126+
127+
test('bigger than actual body', () async {
128+
httpServerChannel.sink.add('content-length: 100\r\n');
129+
await expectLater(
130+
client.get(Uri.http(host, '')), throwsA(isA<ClientException>()));
131+
});
57132
});
58133
});
59134
}

pkgs/java_http/lib/src/java_client.dart

+21-18
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import 'third_party/java/lang/System.dart';
1414
import 'third_party/java/net/HttpURLConnection.dart';
1515
import 'third_party/java/net/URL.dart';
1616

17+
final _digitRegex = RegExp(r'^\d+$');
18+
1719
// TODO: Add a description of the implementation.
1820
// Look at the description of cronet_client.dart and cupertino_client.dart for
1921
// examples.
@@ -69,7 +71,8 @@ class JavaClient extends BaseClient {
6971
});
7072

7173
return StreamedResponse(Stream.value(responseBody), statusCode,
72-
contentLength: _contentLengthHeader(request, responseHeaders),
74+
contentLength:
75+
_contentLengthHeader(request, responseHeaders, responseBody.length),
7376
request: request,
7477
headers: responseHeaders,
7578
reasonPhrase: reasonPhrase);
@@ -112,28 +115,28 @@ class JavaClient extends BaseClient {
112115
if (headerName.isNull) continue;
113116

114117
headers
115-
.putIfAbsent(headerName.toDartString(), () => [])
118+
.putIfAbsent(headerName.toDartString().toLowerCase(), () => [])
116119
.add(headerValue.toDartString());
117120
}
118121

119-
return headers
120-
.map((key, value) => MapEntry(key.toLowerCase(), value.join(',')));
122+
return headers.map((key, value) => MapEntry(key, value.join(',')));
121123
}
122124

123-
int? _contentLengthHeader(BaseRequest request, Map<String, String> headers) {
124-
final contentLengthHeader = headers['content-length'];
125-
126-
// Return null if the content length header is not set.
127-
if (contentLengthHeader == null) return null;
128-
129-
// Throw ClientException if the content length header is not an integer.
130-
final contentLength = int.tryParse(contentLengthHeader);
131-
if (contentLength == null) {
132-
throw ClientException(
133-
'Invalid content-length header: $contentLengthHeader. '
134-
'Content-length must be a non-negative integer.',
135-
request.url,
136-
);
125+
int? _contentLengthHeader(
126+
BaseRequest request, Map<String, String> headers, int bodyLength) {
127+
int? contentLength;
128+
switch (headers['content-length']) {
129+
case final contentLengthHeader?
130+
when !_digitRegex.hasMatch(contentLengthHeader):
131+
throw ClientException(
132+
'Invalid content-length header [$contentLengthHeader].',
133+
request.url,
134+
);
135+
case final contentLengthHeader?:
136+
contentLength = int.parse(contentLengthHeader);
137+
if (bodyLength < contentLength) {
138+
throw ClientException('Unexpected end of body', request.url);
139+
}
137140
}
138141

139142
return contentLength;

0 commit comments

Comments
 (0)