Repository: nodejs/undici (2025-08-24T09:39:52Z)

Issue:
HTTP2 client doesn't handle multiple cookie values correctly
## Bug Description

[[RFC9113], Section 8.2.3](https://www.rfc-editor.org/rfc/rfc9113#section-8.2.3)

> The [Cookie header field](https://www.rfc-editor.org/rfc/rfc9113#RFC6265) [[COOKIE](https://www.rfc-editor.org/rfc/rfc9113#RFC6265)] uses a semicolon (";") to delimit cookie-pairs (or "crumbs").

Currently the [implementation](https://github.com/nodejs/undici/blob/main/lib/dispatcher/client-h2.js#L290) has no special case for the `cookie` header and uses commas (",") as delimiter.

Using semicolon as delimiter should be an easy fix, otoh the linked RFC section continues:

> To allow for better compression efficiency, the Cookie header field MAY be split into separate header fields, each with one or more cookie-pairs.

So maybe instead of

```
cookie: a=b; c=d; e=f
```

undici should send

```
cookie: a=b
cookie: c=d
cookie: e=f
```

🤔

## Reproducible By

```js
import http2 from "node:http2";
import { H2CClient } from "undici";

const port = process.env.PORT ?? 3001;

http2
  .createServer((req, res) => {
    console.log(req.headers);
    res.end();
  })
  .listen(port, async () => {
    const headers = { cookie: ["a=b", "c=d", "e=f"] };
    const client = new H2CClient(`http://localhost:${port}`);
    await client.request({ path: "/", method: "GET", headers });
  });
```

```
[Object: null prototype] {
  ':authority': 'localhost:3001',
  ':method': 'GET',
  ':path': '/',
  ':scheme': 'https',
  cookie: 'a=b, c=d, e=f',
  Symbol(sensitiveHeaders): [ 'cookie' ]
}
```


Discussion:
It sadly doesn't seems possible given the `node:http2` implementation, nevertheless opened #4453 to address the issue



Patch:
diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js
index 3fc63848801..773247d9780 100644
--- a/lib/dispatcher/client-h2.js
+++ b/lib/dispatcher/client-h2.js
@@ -292,6 +292,16 @@ function writeH2 (client, request) {
     const key = reqHeaders[n + 0]
     const val = reqHeaders[n + 1]
 
+    if (key === 'cookie') {
+      if (headers[key] != null) {
+        headers[key] = Array.isArray(headers[key]) ? (headers[key].push(val), headers[key]) : [headers[key], val]
+      } else {
+        headers[key] = val
+      }
+
+      continue
+    }
+
     if (Array.isArray(val)) {
       for (let i = 0; i < val.length; i++) {
         if (headers[key]) {


Test patch:
diff --git a/test/http2.js b/test/http2.js
index 83e14220bc8..db1a2f66c09 100644
--- a/test/http2.js
+++ b/test/http2.js
@@ -173,6 +173,89 @@ test('Should support H2 connection (headers as array)', async t => {
   t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!')
 })
 
+test('Should support multiple header values with semicolon separator', async t => {
+  const body = []
+  const body2 = []
+  const expectedCookieHeaders = ['a=b', 'c=d', 'e=f']
+  const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
+
+  server.on('stream', (stream, headers) => {
+    t.strictEqual(headers['x-my-header'], 'foo, bar')
+    t.strictEqual(headers['x-my-drink'], 'coffee, tea, water')
+    t.strictEqual(headers['x-other'], 'value')
+    t.strictEqual(headers['cookie'], expectedCookieHeaders.join('; '))
+    t.strictEqual(headers[':method'], 'GET')
+    stream.respond({
+      'content-type': 'text/plain; charset=utf-8',
+      'x-custom-h2': 'hello',
+      ':status': 200
+    })
+    stream.end('hello h2!')
+  })
+
+  server.listen(0)
+  await once(server, 'listening')
+
+  const client = new Client(`https://localhost:${server.address().port}`, {
+    connect: {
+      rejectUnauthorized: false
+    },
+    allowH2: true
+  })
+
+  t = tspl(t, { plan: 9 * 2 })
+  after(() => server.close())
+  after(() => client.close())
+
+  const response = await client.request({
+    path: '/',
+    method: 'GET',
+    headers: [
+      'x-my-header', 'foo',
+      'x-my-drink', ['coffee', 'tea'],
+      'x-my-drink', 'water',
+      'X-My-Header', 'bar',
+      'x-other', 'value',
+      'cookie', expectedCookieHeaders
+    ]
+  })
+
+  response.body.on('data', chunk => {
+    body.push(chunk)
+  })
+
+  await once(response.body, 'end')
+  t.strictEqual(response.statusCode, 200)
+  t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8')
+  t.strictEqual(response.headers['x-custom-h2'], 'hello')
+  t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!')
+
+  const response2 = await client.request({
+    path: '/',
+    method: 'GET',
+    headers: [
+      'x-my-header', 'foo',
+      'x-my-drink', ['coffee', 'tea'],
+      'cookie', 'a=b',
+      'x-my-drink', 'water',
+      'X-My-Header', 'bar',
+      'cookie', 'c=d',
+      'x-other', 'value',
+      'cookie', 'e=f'
+    ]
+  })
+
+  response2.body.on('data', chunk => {
+    body2.push(chunk)
+  })
+
+  await once(response2.body, 'end')
+  t.strictEqual(response2.statusCode, 200)
+  t.strictEqual(response2.headers['content-type'], 'text/plain; charset=utf-8')
+  t.strictEqual(response2.headers['x-custom-h2'], 'hello')
+  t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!')
+})
+
 test('Should support H2 connection(POST Buffer)', async t => {
   const server = createSecureServer({ ...await pem.generate({ opts: { keySize: 2048 } }), allowHTTP1: false })