cloudflare / cloudflare/workers-sdk

miniflare: WebSocket 101 upgrade response collapses multiple Set-Cookie headers

Open
#14,145 0 comments 0 reactions 1 assignee Claimed by @MattieTK View on GitHub
package:miniflare
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.