From 4f2c27493f7b6ddc4da23183523e43daee3324a0 Mon Sep 17 00:00:00 2001 From: Pablo Garcia Date: Thu, 13 Aug 2026 16:11:24 +0200 Subject: [PATCH] Expose kResponseHeaders via Symbol.for for user-callable handleUpgrade When user code calls Server#handleUpgrade from its own 'upgrade' listener (such as the README example (C) with a manual http.Server), the underlying WebSocketResponse would overwrite any response headers previously attached to the request with an empty object, making it impossible to set CORS (or any other) headers on the upgrade response without using the .cors option. Switch kResponseHeaders to Symbol.for("engine.io:responseHeaders") so it is reachable from user code, and merge any pre-existing header bag into the WebSocketResponse's initial headers instead of starting from {}. Closes socketio/socket.io#5497 --- packages/engine.io/README.md | 22 +++++++++++++ packages/engine.io/lib/server.ts | 12 ++++++-- packages/engine.io/test/server.js | 51 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/engine.io/README.md b/packages/engine.io/README.md index f33f788f4d..0aed35a7eb 100644 --- a/packages/engine.io/README.md +++ b/packages/engine.io/README.md @@ -299,6 +299,28 @@ to a single process. - `net.Stream`: TCP socket for the request - `Buffer`: legacy tail bytes - **Returns** `Server` for chaining + - **Attaching custom upgrade response headers** + + When you call `Server#handleUpgrade` from your own `'upgrade'` listener + (see example (C) above), you can attach extra headers to the upgrade + response by setting `req[Symbol.for("engine.io:responseHeaders")]` to an + object before calling `handleUpgrade`. Each property is sent as an + HTTP header. Example for setting CORS headers without using the `cors` + option (which is incompatible with WebTransport): + + ```js + const httpServer = require("http").createServer(); + + httpServer.on("upgrade", (req, socket, head) => { + if (req.url?.startsWith("/engine.io/")) { + req[Symbol.for("engine.io:responseHeaders")] = { + "Access-Control-Allow-Origin": req.headers.origin || "*", + "Access-Control-Allow-Credentials": "true", + }; + engine.handleUpgrade(req, socket, head); + } + }); + ``` - `attach` - Attach this Server instance to an `http.Server` - Captures `upgrade` requests for a `http.Server`. In other words, makes diff --git a/packages/engine.io/lib/server.ts b/packages/engine.io/lib/server.ts index 2d22325728..4834c06ebd 100644 --- a/packages/engine.io/lib/server.ts +++ b/packages/engine.io/lib/server.ts @@ -25,7 +25,11 @@ import { objectFromEntries } from "./utils/objectFromEntries"; const debug = debugModule("engine"); -const kResponseHeaders = Symbol("responseHeaders"); +// Exposed via the runtime-shared `Symbol.for` registry so user code calling +// `Server#handleUpgrade` directly can attach additional upgrade-response +// headers (e.g. CORS headers when the `cors` middleware option is not used). +// See https://github.com/socketio/socket.io/issues/5497. +export const kResponseHeaders = Symbol.for("engine.io:responseHeaders"); type TransportName = "polling" | "websocket" | "webtransport"; @@ -675,8 +679,10 @@ class WebSocketResponse { readonly req, readonly socket: Duplex, ) { - // temporarily store the response headers on the req object (see the "headers" event) - req[kResponseHeaders] = {}; + // temporarily store the response headers on the req object (see the "headers" event). + // preserve any additional headers that the caller attached to the request before + // calling Server#handleUpgrade (see issue #5497). + req[kResponseHeaders] = { ...(req[kResponseHeaders] || {}) }; } public setHeader(name: string, value: any) { diff --git a/packages/engine.io/test/server.js b/packages/engine.io/test/server.js index bbfa860681..37640d63b1 100644 --- a/packages/engine.io/test/server.js +++ b/packages/engine.io/test/server.js @@ -3684,6 +3684,57 @@ describe("server", () => { }); }); }); + + it("should forward extra upgrade-response headers set via the shared-symbol hook", function (done) { + if (process.env.EIO_WS_ENGINE === "eiows") { + return this.skip(); + } + // kResponseHeaders used to be a private Symbol unique to this module, + // so user code calling Server#handleUpgrade directly could not attach + // additional upgrade-response headers. See issue #5497. + const kResponseHeaders = Symbol.for("engine.io:responseHeaders"); + + const httpServer = http.createServer(); + const localEngine = new Server(); + let extraSeen = false; + + // Capture the engine's emitted 'headers' payload once the upgrade is + // through. We only need to confirm the extra headers arrive; the + // handshake itself is validated by the connected client below. + localEngine.on("headers", (headers) => { + if ( + headers["x-test-cors"] === "*" && + headers["x-test-extra"] === "ok" + ) { + extraSeen = true; + } + }); + + httpServer.listen(0, () => { + const port = httpServer.address().port; + + httpServer.on("upgrade", (req, socket, head) => { + if (req.url?.startsWith("/engine.io/")) { + req[kResponseHeaders] = { + "x-test-cors": "*", + "x-test-extra": "ok", + }; + return localEngine.handleUpgrade(req, socket, head); + } + }); + + client = new ClientSocket(`ws://localhost:${port}`); + + client.on("open", () => { + client.close(); + httpServer.close(() => { + expect(extraSeen).to.be(true); + done(); + }); + }); + }); + engine = localEngine; + }); }); describe("cors", () => {