Repository: nodejs/undici (JavaScript)

Issue:
Exception parsing HTTP/2 Headers when multiple set-cookie is sent
## Bug Description

By making requests to websites that return multiple set-cookie header with HTTP/2 enabled, an exception is triggered while processing the headers

## Reproducible By

https://replit.com/@gava97/H2-headers

## Expected Behavior

No exception thrown, headers parsed correctly

## Logs & Screenshots

(node:1160) [UNDICI-H2] Warning: H2 support is experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)
TypeError: fetch failed
    at fetch (/home/runner/H2-headers/node_modules/undici/index.js:109:13)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async file:///home/runner/H2-headers/index.js:8:13 {
  [cause]: TypeError: Cannot read properties of undefined (reading 'toString')
      at Object.onHeaders (/home/runner/H2-headers/node_modules/undici/lib/web/fetch/index.js:2135:97)
      at Request.onHeaders (/home/runner/H2-headers/node_modules/undici/lib/core/request.js:243:29)
      at ClientHttp2Stream.<anonymous> (/home/runner/H2-headers/node_modules/undici/lib/dispatcher/client-h2.js:420:17)
      at Object.onceWrapper (node:events:633:26)
      at ClientHttp2Stream.emit (node:events:518:28)
      at emit (node:internal/http2/core:331:3)
      at process.processTicksAndRejections (node:internal/process/task_queues:85:22)
      
      
![image](https://github.com/nodejs/undici/assets/4953714/46e8ee08-4bd0-44ba-81d2-ebf8937ddd3d)

## Environment

Node v20.11.1
undici 6.11.1

### Additional context

This bug is present onliy in undici >= 6.11



Discussion:
Yes, we changed the way we parse the response headers; it seems the root cause goes to the way we parse the `setCookie`, as with `node:http2`, multiple `set-cookie` headers are consolidated into an array of multiple values, we need to change that so `fetch` can parse it accordingly to the spec.

Working on a fix



Patch:
diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js
index 6b48ab9904e..1fedae88715 100644
--- a/lib/dispatcher/client-h2.js
+++ b/lib/dispatcher/client-h2.js
@@ -55,14 +55,20 @@ const {
 } = http2
 
 function parseH2Headers (headers) {
-  // set-cookie is always an array. Duplicates are added to the array.
-  // For duplicate cookie headers, the values are joined together with '; '.
-  headers = Object.entries(headers).flat(2)
-
   const result = []
 
-  for (const header of headers) {
-    result.push(Buffer.from(header))
+  for (const [name, value] of Object.entries(headers)) {
+    // h2 may concat the header value by array
+    // e.g. Set-Cookie
+    if (Array.isArray(value)) {
+      for (const subvalue of value) {
+        // we need to provide each header value of header name
+        // because the headers handler expect name-value pair
+        result.push(Buffer.from(name), Buffer.from(subvalue))
+      }
+    } else {
+      result.push(Buffer.from(name), Buffer.from(value))
+    }
   }
 
   return result


Test patch:
diff --git a/test/fetch/http2.js b/test/fetch/http2.js
index 1cef1d00ce6..f64756de788 100644
--- a/test/fetch/http2.js
+++ b/test/fetch/http2.js
@@ -462,3 +462,48 @@ test('Issue #2386', async (t) => {
   controller.abort()
   ok(true)
 })
+
+test('Issue #3046', async (t) => {
+  const server = createSecureServer(pem)
+
+  const { strictEqual, deepStrictEqual } = tspl(t, { plan: 6 })
+
+  server.on('stream', async (stream, headers) => {
+    strictEqual(headers[':method'], 'GET')
+    strictEqual(headers[':path'], '/')
+    strictEqual(headers[':scheme'], 'https')
+
+    stream.respond({
+      'set-cookie': ['hello=world', 'foo=bar'],
+      'content-type': 'text/html; charset=utf-8',
+      ':status': 200
+    })
+
+    stream.end('<h1>Hello World</h1>')
+  })
+
+  server.listen(0)
+  await once(server, 'listening')
+
+  const client = new Client(`https://localhost:${server.address().port}`, {
+    connect: {
+      rejectUnauthorized: false
+    },
+    allowH2: true
+  })
+
+  t.after(closeClientAndServerAsPromise(client, server))
+
+  const response = await fetch(
+    `https://localhost:${server.address().port}/`,
+    // Needs to be passed to disable the reject unauthorized
+    {
+      method: 'GET',
+      dispatcher: client
+    }
+  )
+
+  strictEqual(response.status, 200)
+  strictEqual(response.headers.get('content-type'), 'text/html; charset=utf-8')
+  deepStrictEqual(response.headers.getSetCookie(), ['hello=world', 'foo=bar'])
+})