Fix multi-header handling in shelf_proxy (#519)
diff --git a/pkgs/shelf_proxy/CHANGELOG.md b/pkgs/shelf_proxy/CHANGELOG.md
index 1736364..37e518f 100644
--- a/pkgs/shelf_proxy/CHANGELOG.md
+++ b/pkgs/shelf_proxy/CHANGELOG.md
@@ -1,5 +1,8 @@
-## 1.0.5-wip
+## 1.0.5
 
+* Correctly handle multiple `Cookie` headers in requests and `Set-Cookie`
+  headers in responses.
+* Require `package:http` `^1.2.0`.
 * Require Dart `^3.3.0`.
 
 ## 1.0.4
diff --git a/pkgs/shelf_proxy/lib/shelf_proxy.dart b/pkgs/shelf_proxy/lib/shelf_proxy.dart
index 7bd3c86..12f97f6 100644
--- a/pkgs/shelf_proxy/lib/shelf_proxy.dart
+++ b/pkgs/shelf_proxy/lib/shelf_proxy.dart
@@ -41,9 +41,16 @@
     // http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.8
     final requestUrl = uri.resolve(serverRequest.url.toString());
     final clientRequest = http.StreamedRequest(serverRequest.method, requestUrl)
-      ..followRedirects = false
-      ..headers.addAll(serverRequest.headers)
-      ..headers['Host'] = uri.authority;
+      ..followRedirects = false;
+
+    serverRequest.headersAll.forEach((name, values) {
+      // The Cookie header is special: multiple cookies are joined with a
+      // semicolon and a space, while other headers use a comma.
+      // See https://datatracker.ietf.org/doc/html/rfc6265#section-5.4
+      clientRequest.headers[name] =
+          values.join(name.toLowerCase() == 'cookie' ? '; ' : ', ');
+    });
+    clientRequest.headers['Host'] = uri.authority;
 
     // Add a Via header. See
     // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.45
@@ -57,42 +64,47 @@
         .whenComplete(clientRequest.sink.close)
         .ignore();
     final clientResponse = await nonNullClient.send(clientRequest);
+    final headers = clientResponse.headers;
+    final headersAll =
+        Map<String, List<String>>.from(clientResponse.headersSplitValues);
+
     // Add a Via header. See
     // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.45
-    _addHeader(clientResponse.headers, 'via', '1.1 $proxyName');
+    headersAll.update('via', (values) => [...values, '1.1 $proxyName'],
+        ifAbsent: () => ['1.1 $proxyName']);
 
     // Remove the transfer-encoding since the body has already been decoded by
     // [client].
-    clientResponse.headers.remove('transfer-encoding');
+    headersAll.remove('transfer-encoding');
 
     // If the original response was gzipped, it will be decoded by [client]
     // and we'll have no way of knowing its actual content-length.
-    if (clientResponse.headers['content-encoding'] == 'gzip') {
-      clientResponse.headers.remove('content-encoding');
-      clientResponse.headers.remove('content-length');
+    if (headersAll['content-encoding']?.contains('gzip') ?? false) {
+      headersAll.remove('content-encoding');
+      headersAll.remove('content-length');
 
       // Add a Warning header. See
       // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.5.2
-      _addHeader(
-          clientResponse.headers, 'warning', '214 $proxyName "GZIP decoded"');
+      headersAll.update(
+          'warning', (values) => [...values, '214 $proxyName "GZIP decoded"'],
+          ifAbsent: () => ['214 $proxyName "GZIP decoded"']);
     }
 
     // Make sure the Location header is pointing to the proxy server rather
     // than the destination server, if possible.
-    if (clientResponse.isRedirect &&
-        clientResponse.headers.containsKey('location')) {
-      final location =
-          requestUrl.resolve(clientResponse.headers['location']!).toString();
+    if (clientResponse.isRedirect && headers.containsKey('location')) {
+      final location = requestUrl.resolve(headers['location']!).toString();
       if (p.url.isWithin(uri.toString(), location)) {
-        clientResponse.headers['location'] =
-            '/${p.url.relative(location, from: uri.toString())}';
+        headersAll['location'] = [
+          '/${p.url.relative(location, from: uri.toString())}'
+        ];
       } else {
-        clientResponse.headers['location'] = location;
+        headersAll['location'] = [location];
       }
     }
 
     return Response(clientResponse.statusCode,
-        body: clientResponse.stream, headers: clientResponse.headers);
+        body: clientResponse.stream, headers: headersAll);
   };
 }
 
diff --git a/pkgs/shelf_proxy/pubspec.yaml b/pkgs/shelf_proxy/pubspec.yaml
index 2e4125f..3fa2457 100644
--- a/pkgs/shelf_proxy/pubspec.yaml
+++ b/pkgs/shelf_proxy/pubspec.yaml
@@ -1,5 +1,5 @@
 name: shelf_proxy
-version: 1.0.5-wip
+version: 1.0.5
 description: A shelf handler for proxying HTTP requests to another server.
 repository: https://github.com/dart-lang/shelf/tree/master/pkgs/shelf_proxy
 issue_tracker: https://github.com/dart-lang/shelf/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Ashelf_proxy
@@ -12,7 +12,7 @@
   sdk: ^3.3.0
 
 dependencies:
-  http: '>=0.13.0 <2.0.0'
+  http: ^1.2.0
   path: ^1.8.0
   shelf: ^1.0.0
 
diff --git a/pkgs/shelf_proxy/test/shelf_proxy_test.dart b/pkgs/shelf_proxy/test/shelf_proxy_test.dart
index 822d262..5f3ceaa 100644
--- a/pkgs/shelf_proxy/test/shelf_proxy_test.dart
+++ b/pkgs/shelf_proxy/test/shelf_proxy_test.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:io';
 
 import 'package:http/http.dart' as http;
 import 'package:http/testing.dart';
@@ -38,6 +39,21 @@
       await get(headers: {'foo': 'bar', 'accept': '*/*'});
     });
 
+    test('joins multiple request cookies with a semicolon', () async {
+      final client = MockClient((request) {
+        expect(request.headers,
+            containsPair('cookie', 'cookie1=value1; cookie2=value2'));
+        return Future.value(http.Response(':)', 200));
+      });
+
+      final handler = proxyHandler('http://dartlang.org', client: client);
+      final request =
+          shelf.Request('GET', Uri.parse('http://localhost/'), headers: {
+        'cookie': ['cookie1=value1', 'cookie2=value2']
+      });
+      await handler(request);
+    });
+
     test('forwards request body', () async {
       await createProxy((request) {
         expect(request.readAsString(), completion(equals('hello, server')));
@@ -64,6 +80,28 @@
       expect(response.headers, containsPair('accept', '*/*'));
     });
 
+    test('preserves multiple Set-Cookie headers in response, including dates',
+        () async {
+      await createProxy((request) {
+        return shelf.Response.ok(':)', headers: {
+          'set-cookie': [
+            'cookie1=value1; Expires=Mon, 20 Apr 2026 17:22:59 GMT',
+            'cookie2=value2'
+          ],
+        });
+      });
+
+      final client = HttpClient();
+      final request = await client.getUrl(proxyUri);
+      final response = await request.close();
+
+      final setCookies = response.headers['set-cookie'];
+      expect(setCookies, [
+        'cookie1=value1; Expires=Mon, 20 Apr 2026 17:22:59 GMT',
+        'cookie2=value2'
+      ]);
+    });
+
     test('forwards response body', () async {
       await createProxy((request) => shelf.Response.ok('hello, client'));