From e24a69e01c1b2694161274cc56cb0d9d61a95944 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 17 Jan 2026 08:00:35 +0000 Subject: [PATCH 1/3] fix(websocket): add Authorization header from URL credentials When creating a WebSocket connection with embedded credentials in the URL (e.g., ws://foo:bar@localhost:1337/), the Authorization header was not being generated. This aligns the behavior with the ws package and web standards. Fixes: https://github.com/nodejs/undici/issues/4744 --- lib/web/websocket/connection.js | 8 +++ test/websocket/url-credentials.js | 89 +++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 test/websocket/url-credentials.js diff --git a/lib/web/websocket/connection.js b/lib/web/websocket/connection.js index 06b62047e9c..40566ac9826 100644 --- a/lib/web/websocket/connection.js +++ b/lib/web/websocket/connection.js @@ -52,6 +52,14 @@ function establishWebSocketConnection (url, protocols, client, handler, options) request.headersList = headersList } + // If the URL has credentials, add Authorization header (unless already set by options.headers) + // @see https://fetch.spec.whatwg.org/#concept-basic-fetch (step 12.2.3) + if ((url.username || url.password) && !request.headersList.contains('authorization', true)) { + const credentials = `${url.username}:${url.password}` + const encoded = Buffer.from(credentials).toString('base64') + request.headersList.append('authorization', `Basic ${encoded}`, true) + } + // 3. Append (`Upgrade`, `websocket`) to request’s header list. // 4. Append (`Connection`, `Upgrade`) to request’s header list. // Note: both of these are handled by undici currently. diff --git a/test/websocket/url-credentials.js b/test/websocket/url-credentials.js new file mode 100644 index 00000000000..eb81b7588c8 --- /dev/null +++ b/test/websocket/url-credentials.js @@ -0,0 +1,89 @@ +'use strict' + +const { test } = require('node:test') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { WebSocket } = require('../..') + +test('WebSocket sets Authorization header from URL credentials', async (t) => { + const server = createServer((req, res) => { + const expected = 'Basic ' + Buffer.from('foo:bar').toString('base64') + t.assert.strictEqual(req.headers.authorization, expected) + res.end() + }).listen(0) + + await once(server, 'listening') + t.after(() => server.close()) + + const ws = new WebSocket(`ws://foo:bar@localhost:${server.address().port}/`) + ws.onerror = () => {} // Expected - server doesn't complete WebSocket handshake + + await once(server, 'request') +}) + +test('WebSocket sets Authorization header with only username', async (t) => { + const server = createServer((req, res) => { + const expected = 'Basic ' + Buffer.from('foo:').toString('base64') + t.assert.strictEqual(req.headers.authorization, expected) + res.end() + }).listen(0) + + await once(server, 'listening') + t.after(() => server.close()) + + const ws = new WebSocket(`ws://foo@localhost:${server.address().port}/`) + ws.onerror = () => {} + + await once(server, 'request') +}) + +test('WebSocket sets Authorization header with only password', async (t) => { + const server = createServer((req, res) => { + const expected = 'Basic ' + Buffer.from(':bar').toString('base64') + t.assert.strictEqual(req.headers.authorization, expected) + res.end() + }).listen(0) + + await once(server, 'listening') + t.after(() => server.close()) + + const ws = new WebSocket(`ws://:bar@localhost:${server.address().port}/`) + ws.onerror = () => {} + + await once(server, 'request') +}) + +test('WebSocket does not set Authorization header when no credentials', async (t) => { + const server = createServer((req, res) => { + t.assert.strictEqual(req.headers.authorization, undefined) + res.end() + }).listen(0) + + await once(server, 'listening') + t.after(() => server.close()) + + const ws = new WebSocket(`ws://localhost:${server.address().port}/`) + ws.onerror = () => {} + + await once(server, 'request') +}) + +test('WebSocket custom Authorization header takes precedence over URL credentials', async (t) => { + const customAuth = 'Bearer mytoken' + const server = createServer((req, res) => { + t.assert.strictEqual(req.headers.authorization, customAuth) + res.end() + }).listen(0) + + await once(server, 'listening') + t.after(() => server.close()) + + const ws = new WebSocket(`ws://foo:bar@localhost:${server.address().port}/`, { + headers: { + Authorization: customAuth + } + }) + ws.onerror = () => {} + + await once(server, 'request') +}) From 15ba27b9fc9088afb2e673a49dca14d48c197310 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 17 Jan 2026 09:02:24 +0000 Subject: [PATCH 2/3] test(wpt): configure CA certificate for TLS connections The WPT server uses pregenerated self-signed certificates. Configure the global dispatcher to trust the CA certificate so that wss:// and https:// connections work properly in WPT tests. --- test/web-platform-tests/runner/test-runner.mjs | 11 +++++++++++ test/web-platform-tests/wpt-runner.mjs | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/test/web-platform-tests/runner/test-runner.mjs b/test/web-platform-tests/runner/test-runner.mjs index 4da0d4b0cca..2555738ee67 100644 --- a/test/web-platform-tests/runner/test-runner.mjs +++ b/test/web-platform-tests/runner/test-runner.mjs @@ -5,6 +5,8 @@ import { Request, Response, setGlobalOrigin, + setGlobalDispatcher, + Agent, CloseEvent, WebSocket, caches, @@ -16,6 +18,15 @@ import { Cache } from '../../../lib/web/cache/cache.js' import { CacheStorage } from '../../../lib/web/cache/cachestorage.js' import { runInThisContext } from 'node:vm' import { debuglog } from 'node:util' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +// Configure global dispatcher to trust WPT server's CA certificate +const caCertPath = join(import.meta.dirname, 'certs/cacert.pem') +const ca = readFileSync(caCertPath) +setGlobalDispatcher(new Agent({ + connect: { ca } +})) const globalPropertyDescriptors = { writable: true, diff --git a/test/web-platform-tests/wpt-runner.mjs b/test/web-platform-tests/wpt-runner.mjs index 378b79b5977..b9bb94c2346 100644 --- a/test/web-platform-tests/wpt-runner.mjs +++ b/test/web-platform-tests/wpt-runner.mjs @@ -13,6 +13,7 @@ import * as jsondiffpatch from 'jsondiffpatch' const WPT_DIR = join(import.meta.dirname, 'wpt') const EXPECTATION_PATH = join(import.meta.dirname, 'expectation.json') +const CA_CERT_PATH = join(import.meta.dirname, 'runner/certs/cacert.pem') const log = debuglog('UNDICI_WPT') @@ -76,7 +77,8 @@ function runSingleTest (url, options, expectation, timeout = 10000) { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, - NO_COLOR: '1' + NO_COLOR: '1', + NODE_EXTRA_CA_CERTS: CA_CERT_PATH } }) From 4c1ec04f7e18c7f90783b0afcac48093607c0e43 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 17 Jan 2026 10:21:50 +0000 Subject: [PATCH 3/3] fix(websocket): fire close event asynchronously when closing in CONNECTING state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When close() is called on a WebSocket in the CONNECTING state, events were firing synchronously during close() instead of being queued, and readyState was transitioning incorrectly (CONNECTING → CLOSED → CLOSING instead of CONNECTING → CLOSING → CLOSED). - Queue onSocketClose() via queueMicrotask in failWebsocketConnection() - Add guard in #onSocketClose to prevent duplicate calls Fixes #4741 Fixes #4742 --- lib/web/websocket/connection.js | 3 ++- lib/web/websocket/websocket.js | 5 +++++ test/web-platform-tests/expectation.json | 27 ++++++++---------------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/lib/web/websocket/connection.js b/lib/web/websocket/connection.js index 40566ac9826..3b6aa44054d 100644 --- a/lib/web/websocket/connection.js +++ b/lib/web/websocket/connection.js @@ -323,7 +323,8 @@ function failWebsocketConnection (handler, code, reason, cause) { if (isConnecting(handler.readyState)) { // If the connection was not established, we must still emit an 'error' and 'close' events - handler.onSocketClose() + // Queue via microtask so close() returns before events fire and readyState transitions correctly + queueMicrotask(() => handler.onSocketClose()) } else if (handler.socket?.destroyed === false) { handler.socket.destroy() } diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js index 331497677a3..f56e8bab1c0 100644 --- a/lib/web/websocket/websocket.js +++ b/lib/web/websocket/websocket.js @@ -554,6 +554,11 @@ class WebSocket extends EventTarget { * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4 */ #onSocketClose () { + // Guard against duplicate calls + if (this.#handler.readyState === states.CLOSED) { + return + } + // If the TCP connection was closed after the // WebSocket closing handshake was completed, the WebSocket connection // is said to have been closed _cleanly_. diff --git a/test/web-platform-tests/expectation.json b/test/web-platform-tests/expectation.json index 6678260cab5..06933c4a27f 100644 --- a/test/web-platform-tests/expectation.json +++ b/test/web-platform-tests/expectation.json @@ -37734,8 +37734,7 @@ "cases": [ { "name": "close event should be fired asynchronously when WebSocket is connecting", - "success": false, - "message": "assert_true: ws.close() should have returned expected true got false" + "success": true } ] }, @@ -37744,8 +37743,7 @@ "cases": [ { "name": "close event should be fired asynchronously when WebSocket is connecting", - "success": false, - "message": "assert_true: ws.close() should have returned expected true got false" + "success": true } ] }, @@ -37754,8 +37752,7 @@ "cases": [ { "name": "close event should be fired asynchronously when WebSocket is connecting", - "success": false, - "message": "assert_true: ws.close() should have returned expected true got false" + "success": true } ] }, @@ -37764,8 +37761,7 @@ "cases": [ { "name": "close-connecting", - "success": false, - "message": "assert_unreached: Reached unreachable code" + "success": true } ] }, @@ -37774,8 +37770,7 @@ "cases": [ { "name": "close-connecting", - "success": false, - "message": "assert_unreached: Reached unreachable code" + "success": true } ] }, @@ -37784,8 +37779,7 @@ "cases": [ { "name": "close-multiple", - "success": false, - "message": "assert_equals: expected 1 but got 2" + "success": true } ] }, @@ -37794,8 +37788,7 @@ "cases": [ { "name": "close-multiple", - "success": false, - "message": "assert_equals: expected 1 but got 2" + "success": true } ] }, @@ -37804,8 +37797,7 @@ "cases": [ { "name": "close-nested", - "success": false, - "message": "assert_equals: expected 1 but got 2" + "success": true } ] }, @@ -37814,8 +37806,7 @@ "cases": [ { "name": "close-nested", - "success": false, - "message": "assert_equals: expected 1 but got 2" + "success": true } ] },