From 46d238fc0a70613d011ec5fb5eefd7b17fb92371 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:15:21 -0700 Subject: [PATCH] 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 --- packages/runtime/src/scr_fetch.c | 27 +++++++++++- tests/corpus/fetch-request-set-cookie-wire.ts | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/fetch-request-set-cookie-wire.ts diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index dc53c7f22..f5970ea0f 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -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); } diff --git a/tests/corpus/fetch-request-set-cookie-wire.ts b/tests/corpus/fetch-request-set-cookie-wire.ts new file mode 100644 index 000000000..54789a3fb --- /dev/null +++ b/tests/corpus/fetch-request-set-cookie-wire.ts @@ -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(); +});