Send repeated Set-Cookie request headers as one combined line

Headers stores each Set-Cookie value separately, but undici dispatches its
header list's combined value: one line with the values joined by ", " at
the position of the first one. fetch() wrote one line per value.

The request serializer now joins later Set-Cookie values into the first
entry and skips the rest, for every HeadersInit form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 15:16:14 -07:00
co-authored by Claude Opus 5.5
parent 77b137268d
commit 46d238fc0a
2 changed files with 70 additions and 1 deletions
+26 -1
View File
@@ -4454,14 +4454,39 @@ static bool sf_start_hop(SfTransfer *t) {
}
if (connection_close) sf_push_header_text(headers, "connection", "close", 5);
else sf_push_header_text(headers, "connection", "keep-alive", 10);
/* Headers keeps each Set-Cookie value separately, but undici sends its
* header list's combined value: one line, the values joined by ", ",
* at the position of the first one. */
bool set_cookie_sent = false;
for (size_t i = 0; i + 1 < user_len; i += 2) {
ScrStr *name = scr_arr_get_ref(t->headers, (double)i);
ScrStr *value = scr_arr_get_ref(t->headers, (double)(i + 1));
if (sf_eq_ci(name, "host") || sf_eq_ci(name, "connection")) {
if (sf_eq_ci(name, "host") || sf_eq_ci(name, "connection") ||
(set_cookie_sent && sf_eq_ci(name, "set-cookie"))) {
scr_str_release(name);
scr_str_release(value);
continue;
}
if (sf_eq_ci(name, "set-cookie")) {
set_cookie_sent = true;
for (size_t j = i + 2; j + 1 < user_len; j += 2) {
ScrStr *later = scr_arr_get_ref(t->headers, (double)j);
bool match = sf_eq_ci(later, "set-cookie");
scr_str_release(later);
if (!match) continue;
ScrStr *next = scr_arr_get_ref(t->headers, (double)(j + 1));
size_t joined_len = value->len + 2 + next->len;
char *joined = malloc(joined_len ? joined_len : 1);
if (!joined) sf_oom();
memcpy(joined, value->data, value->len);
memcpy(joined + value->len, ", ", 2);
memcpy(joined + value->len + 2, next->data, next->len);
scr_str_release(next);
scr_str_release(value);
value = scr_str_new(joined, joined_len);
free(joined);
}
}
scr_arr_push_ref(headers, name);
scr_arr_push_ref(headers, value);
}
@@ -0,0 +1,44 @@
// fetch() sends its header list's combined values: repeated Set-Cookie
// request headers go out as one line, the values joined by ", ", at the
// position of the first one, whatever form the headers were given in.
import { createServer } from "node:http";
function shown(raw: string[]): string {
const lines: string[] = [];
for (let i = 0; i < raw.length; i += 2) {
const name = raw[i]!;
const lower = name.toLowerCase();
if (lower === "host" || lower === "connection" || lower.startsWith("accept") || lower.startsWith("sec-") || lower === "user-agent") continue;
lines.push(`${name}: ${raw[i + 1]}`);
}
return lines.join(" | ");
}
const server = createServer((req, res) => {
res.end(shown(req.rawHeaders));
});
server.listen(0, "127.0.0.1", async () => {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const url = `http://127.0.0.1:${port}/`;
const headers = new Headers();
headers.append("set-cookie", "a=1");
headers.append("x-first", "1");
headers.append("set-cookie", "b=2");
console.log(await (await fetch(url, { headers })).text());
const pairs: [string, string][] = [
["x-before", "0"],
["set-cookie", "c=3"],
["set-cookie", "d=4; Path=/"],
];
console.log(await (await fetch(url, { headers: pairs })).text());
console.log(await (await fetch(url, { headers: { "set-cookie": "e=5", "x-record": "r" } })).text());
const single = new Headers({ "set-cookie": "only=1" });
console.log(await (await fetch(url, { headers: single })).text());
server.close();
});