Repository: nodejs/undici (JavaScript)

Issue:
v7 `RetryAgent` converts header with multiple entries (eg. set-cookie) to `\x00`
## Bug Description

After upgrading from `6.21.0` to `7.1.0` the response `set-cookie` header contains `\x00` instead of actual values.

Expected

```js
{
  server: 'nginx',
  date: 'Mon, 09 Dec 2024 07:11:11 GMT',
  'content-type': 'application/json; charset=utf-8',
  'content-length': '359',
  connection: 'keep-alive',
  'set-cookie': [
    'CloudFront-Policy=xxx; Path=/; Domain=example.com; HTTPOnly',
    'CloudFront-Key-Pair-Id=xxx; Path=/; Domain=example.com; HTTPOnly',
    'CloudFront-Signature=xxx; Path=/; Domain=example.com; HTTPOnly'
  ]
}
```

Received

```js
{
  server: 'nginx',
  date: 'Mon, 09 Dec 2024 07:08:57 GMT',
  'content-type': 'application/json; charset=utf-8',
  'content-length': '364',
  connection: 'keep-alive',
  'set-cookie': '\x00\x00\x00',
}
```

## Reproducible By

<!-- A step by step list on how the bug can be reproduced for examination. -->

## Expected Behavior

<!-- A clear and concise description of what you expected to happen. -->

## Logs & Screenshots

<!-- If applicable, add screenshots to help explain your problem, or 
alternatively add your console logs here. -->

## Environment

<!-- This is just your OS and environment information [e.g. Ubuntu 18.04 LTS,
Node v14.14.0] -->

### Additional context

<!-- Add any other context about the problem here. -->



Discussion:
Can you provide an 
[Minimum Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) to support you better?
Unfortunately I'm unable to reproduce the issue locally. I tried `node:http` and `node:http2` as server and sending multiple `set-cookie` headers works in both cases.

In production I'm fetching from `https://developer.api.autodesk.com` to get signed cookies, see https://aps.autodesk.com/blog/download-derivative-files-using-new-signedcookies-api-without-setting-cookies-first-header

In this case the `set-cookie` header is always `\x00\x00\x00` using v7 but works using v6.

Atm I don't have time to investigate this further.
Unfortunately without reproduction is hard to assess what might be happening; it can be a bad encoding parsing of the signed header while receiving the response, but that can also be due to bad encoding from the source.

Without isolated reproduction is hard to tell what can be going wrong. Happy to have a look if we can isolate the problem
I figured it out. It's a problem with `RetryAgent`

```js
import http from "node:http";

import { Agent, RetryAgent, request } from "undici";

const port = process.env.PORT ?? 3001;

const server = http
  .createServer(async (_req, res) => {
    const headers = [
      ["set-cookie", "a"],
      ["set-cookie", "b"],
      ["set-cookie", "c"]
    ];
    res.writeHead(200, headers);
    res.end();
  })
  .listen(port, () => {
    console.log("Listening on port", port);
  });

const agent = new Agent();
const retryAgent = new RetryAgent(agent);

const ok = await request(`http://localhost:${port}`, { dispatcher: agent });
const fail = await request(`http://localhost:${port}`, { dispatcher: retryAgent });

console.log("ok", ok.headers);
console.log("fail", fail.headers);

server.close();
```

```
Listening on port 3001
ok {
  'set-cookie': [ 'a', 'b', 'c' ],
  date: 'Tue, 10 Dec 2024 18:09:30 GMT',
  connection: 'keep-alive',
  'keep-alive': 'timeout=5',
  'transfer-encoding': 'chunked'
}
fail {
  'set-cookie': '\x00\x00\x00',
  date: 'Tue, 10 Dec 2024 18:09:30 GMT',
  connection: 'keep-alive',
  'keep-alive': 'timeout=5',
  'transfer-encoding': 'chunked'
}
```
https://github.com/nodejs/undici/blob/dd7473c2e0818161d182eab9bf9d709227851870/lib/handler/wrap-handler.js#L66

I guess we know what happens when val is an array.



Patch:
diff --git a/lib/handler/wrap-handler.js b/lib/handler/wrap-handler.js
index 9a0dee3d069..47caa5fa68b 100644
--- a/lib/handler/wrap-handler.js
+++ b/lib/handler/wrap-handler.js
@@ -53,8 +53,7 @@ module.exports = class WrapHandler {
   onRequestUpgrade (controller, statusCode, headers, socket) {
     const rawHeaders = []
     for (const [key, val] of Object.entries(headers)) {
-      // TODO (fix): What if val is Array
-      rawHeaders.push(Buffer.from(key), Buffer.from(val))
+      rawHeaders.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val))
     }
 
     this.#handler.onUpgrade?.(statusCode, rawHeaders, socket)
@@ -63,8 +62,7 @@ module.exports = class WrapHandler {
   onResponseStart (controller, statusCode, headers, statusMessage) {
     const rawHeaders = []
     for (const [key, val] of Object.entries(headers)) {
-      // TODO (fix): What if val is Array
-      rawHeaders.push(Buffer.from(key), Buffer.from(val))
+      rawHeaders.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val))
     }
 
     if (this.#handler.onHeaders?.(statusCode, rawHeaders, () => controller.resume(), statusMessage) === false) {
@@ -81,8 +79,7 @@ module.exports = class WrapHandler {
   onResponseEnd (controller, trailers) {
     const rawTrailers = []
     for (const [key, val] of Object.entries(trailers)) {
-      // TODO (fix): What if val is Array
-      rawTrailers.push(Buffer.from(key), Buffer.from(val))
+      rawTrailers.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val))
     }
 
     this.#handler.onComplete?.(rawTrailers)


Test patch:
diff --git a/test/client-node-max-header-size.js b/test/client-node-max-header-size.js
index 4c23113dde8..2d1d8cbbcf2 100644
--- a/test/client-node-max-header-size.js
+++ b/test/client-node-max-header-size.js
@@ -25,7 +25,7 @@ describe("Node.js' --max-http-header-size cli option", () => {
 
   test("respect Node.js' --max-http-header-size", async (t) => {
     t = tspl(t, { plan: 6 })
-    const command = 'node -e "require(\'.\').request(\'http://localhost:' + server.address().port + '\')"'
+    const command = 'node --disable-warning=ExperimentalWarning -e "require(\'.\').request(\'http://localhost:' + server.address().port + '\')"'
 
     exec(`${command} --max-http-header-size=1`, { stdio: 'pipe' }, (err, stdout, stderr) => {
       t.strictEqual(err.code, 1)
@@ -44,7 +44,7 @@ describe("Node.js' --max-http-header-size cli option", () => {
 
   test('--max-http-header-size with Client API', async (t) => {
     t = tspl(t, { plan: 6 })
-    const command = 'node -e "new (require(\'.\').Client)(new URL(\'http://localhost:200\'))"'
+    const command = 'node --disable-warning=ExperimentalWarning -e "new (require(\'.\').Client)(new URL(\'http://localhost:200\'))"'
 
     exec(`${command} --max-http-header-size=0`, { stdio: 'pipe' }, (err, stdout, stderr) => {
       t.strictEqual(err.code, 1)
diff --git a/test/issue-3934.js b/test/issue-3934.js
new file mode 100644
index 00000000000..19c1544c213
--- /dev/null
+++ b/test/issue-3934.js
@@ -0,0 +1,32 @@
+'use strict'
+
+const { test } = require('node:test')
+const { createServer } = require('node:http')
+const { once } = require('node:events')
+const assert = require('node:assert')
+const { Agent, RetryAgent, request } = require('..')
+
+// https://github.com/nodejs/undici/issues/3934
+test('WrapHandler works with multiple header values', async (t) => {
+  const server = createServer(async (_req, res) => {
+    const headers = [
+      ['set-cookie', 'a'],
+      ['set-cookie', 'b'],
+      ['set-cookie', 'c']
+    ]
+    res.writeHead(200, headers)
+    res.end()
+  }).listen(0)
+
+  await once(server, 'listening')
+  t.after(() => server.close())
+
+  const agent = new Agent()
+  const retryAgent = new RetryAgent(agent)
+
+  const {
+    headers
+  } = await request(`http://localhost:${server.address().port}`, { dispatcher: retryAgent })
+
+  assert.deepStrictEqual(headers['set-cookie'], ['a', 'b', 'c'])
+})