cloudflare / cloudflare/workers-sdk
miniflare: WebSocket 101 upgrade response collapses multiple Set-Cookie headers
- Dominant language
- TypeScript
- Stars
- 4.5k
- Forks
- 1.5k
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 187
Description
### Summary
The "extra headers" path for WebSocket 101 upgrade responses in `Miniflare` collapses multiple `Set-Cookie` headers into a single comma-joined value, which corrupts cookies whose attributes contain commas (most commonly `Expires=...`).
### Location
`packages/miniflare/src/index.ts:1068-1080` — the `'headers'` listener attached to `#webSocketServer`:
```ts
this.#webSocketServer.on("headers", (headers, req) => {
const extra = this.#webSocketExtraHeaders.get(req);
this.#webSocketExtraHeaders.delete(req);
if (extra) {
for (const [key, value] of extra) {
if (!restrictedWebSocketUpgradeHeaders.includes(key.toLowerCase())) {
headers.push(`${key}: ${value}`);
}
}
}
});
```
Iterating a fetch-style `Headers` instance with `for…of` joins all `Set-Cookie` values into a single comma-separated string — the well-known web-platform limitation of `Headers`. When a Worker returns multiple cookies on a 101 upgrade response, the cookies arrive on the wire as one mangled line.
### Why this matters
Cookies routinely include commas in their attributes (most commonly `Expires=Wed, 09 Jun 2026 10:18:14 GMT` or comma-bearing `Path`/`Domain` edge cases), so the joined value is no longer parseable by clients. Setting more than one cookie on a WebSocket handshake response simply doesn't work end-to-end via `wrangler dev` / miniflare.
### Repro sketch
A Worker that returns multiple `Set-Cookie` headers on a 101:
```js
const [client, server] = Object.values(new WebSocketPair());
server.accept();
const headers = new Headers();
headers.append("Set-Cookie", "session=abc; Path=/; Expires=Wed, 09 Jun 2026 10:18:14 GMT");
headers.append("Set-Cookie", "theme=dark; Path=/; HttpOnly");
return new Response(null, { status: 101, webSocket: client, headers });
```
Open a WebSocket via `wrangler dev` (which goes through this miniflare path) and inspect the raw 101 response — it contains a single mangled `Set-Cookie:` line instead of two.
### Suggested fix
Mirror the approach that just landed in `@cloudflare/vite-plugin` in #14117 (`packages/vite-plugin-cloudflare/src/websockets.ts`): special-case `Set-Cookie` via `Headers.getSetCookie()` so multiple values are preserved verbatim, and skip `set-cookie` in the general iteration loop.
```ts
this.#webSocketServer.on("headers", (headers, req) => {
const extra = this.#webSocketExtraHeaders.get(req);
this.#webSocketExtraHeaders.delete(req);
if (!extra) return;
// Preserve multiple Set-Cookie values verbatim (Headers iteration would
// otherwise collapse them into a single comma-joined string).
if (typeof extra.getSetCookie === "function") {
for (const cookie of extra.getSetCookie()) {
headers.push(`Set-Cookie: ${cookie}`);
}
}
for (const [key, value] of extra) {
const lower = key.toLowerCase();
if (lower === "set-cookie") continue;
if (restrictedWebSocketUpgradeHeaders.includes(lower)) continue;
headers.push(`${key}: ${value}`);
}
});
```
A regression test analogous to `forwards response headers from the Worker on the 101 upgrade response` in `packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts` would lock in the contract.
### Notes
- Pre-existing limitation. PR #14117 only addressed the equivalent path in `@cloudflare/vite-plugin`; the `wrangler dev` / miniflare path is still affected.
- Surfaced while reviewing PR #14117. See https://github.com/cloudflare/workers-sdk/pull/14117#issuecomment-4591644179 for context.
- `Headers.getSetCookie()` is available since Node 18.14, well below the repo's Node ≥20 floor.
Contributor guide
Assessment
This issue has not been assessed yet.