diff --git a/lib/web/websocket/connection.js b/lib/web/websocket/connection.js index 06b62047e9c..3b6aa44054d 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. @@ -315,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 } ] }, 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 } }) 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') +})