Skip to content

Commit ea3a06d

Browse files
authored
fix(fetch): preserve path for credentialed URLs (#4892)
1 parent 9b96516 commit ea3a06d

3 files changed

Lines changed: 85 additions & 41 deletions

File tree

lib/web/fetch/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2132,9 +2132,12 @@ async function httpNetworkFetch (
21322132
/** @type {import('../../..').Agent} */
21332133
const agent = fetchParams.controller.dispatcher
21342134

2135+
const path = url.pathname + url.search
2136+
const hasTrailingQuestionMark = url.search.length === 0 && url.href[url.href.length - url.hash.length - 1] === '?'
2137+
21352138
return new Promise((resolve, reject) => agent.dispatch(
21362139
{
2137-
path: url.href.slice(url.href.indexOf(url.host) + url.host.length, url.hash.length ? -url.hash.length : undefined),
2140+
path: hasTrailingQuestionMark ? `${path}?` : path,
21382141
origin: url.origin,
21392142
method: request.method,
21402143
body: agent.isMockActive ? request.body && (request.body.source || request.body.stream) : body,

test/fetch/issue-4897.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict'
2+
3+
const { test } = require('node:test')
4+
const { fetch } = require('../..')
5+
6+
function createAssertingDispatcher (t, expectedPath) {
7+
return {
8+
dispatch (opts, handler) {
9+
t.assert.strictEqual(opts.path, expectedPath)
10+
handler.onError(new Error('stop'))
11+
return true
12+
}
13+
}
14+
}
15+
16+
async function assertPath (t, url, expectedPath) {
17+
const dispatcher = createAssertingDispatcher(t, expectedPath)
18+
19+
await t.assert.rejects(fetch(url, { dispatcher }), (err) => {
20+
t.assert.strictEqual(err.cause?.message, 'stop')
21+
return true
22+
})
23+
}
24+
25+
// https://github.com/nodejs/undici/issues/4897
26+
test('fetch path extraction does not match hostnames inside scheme', async (t) => {
27+
const hosts = ['h', 't', 'p', 'ht', 'tp', 'tt']
28+
29+
for (const scheme of ['http', 'https']) {
30+
for (const host of hosts) {
31+
await t.test(`${scheme}://${host}/test?a=b#frag`, async (t) => {
32+
await assertPath(t, `${scheme}://${host}/test?a=b#frag`, '/test?a=b')
33+
})
34+
}
35+
}
36+
})

test/websocket/issue-4889.js

Lines changed: 45 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
1+
'use strict'
2+
13
const { test } = require('node:test')
2-
const http = require('node:http')
3-
const crypto = require('node:crypto')
4+
const { once } = require('node:events')
5+
const { createServer } = require('node:http')
6+
const { WebSocketServer } = require('ws')
7+
48
const { WebSocket } = require('../..')
5-
const { createDeferredPromise } = require('../../lib/util/promise')
9+
const { closeServerAsPromise } = require('../utils/node-http')
10+
11+
// https://github.com/nodejs/undici/issues/4889
12+
test('websocket auth retry preserves path when URL contains credentials', async (t) => {
13+
const expectedAuth = `Basic ${Buffer.from('user:pass').toString('base64')}`
14+
15+
let attempts = 0
16+
17+
const server = createServer()
18+
const wss = new WebSocketServer({ noServer: true })
19+
20+
t.after(async () => {
21+
for (const client of wss.clients) {
22+
client.terminate()
23+
}
24+
25+
await new Promise((resolve) => wss.close(resolve))
26+
await closeServerAsPromise(server)()
27+
})
628

7-
test('WebSocket basic auth', (t) => {
8-
const server = http.createServer()
29+
server.on('upgrade', (req, socket, head) => {
30+
attempts++
31+
t.assert.strictEqual(req.url, '/path')
932

10-
server.on('upgrade', (req, socket) => {
11-
const auth = req.headers.authorization
12-
if (!auth || auth !== `Basic ${Buffer.from('user:pass').toString('base64')}`) {
33+
if (attempts === 1) {
34+
t.assert.strictEqual(req.headers.authorization, undefined)
1335
socket.write(
1436
'HTTP/1.1 401 Unauthorized\r\n' +
1537
'WWW-Authenticate: Basic realm="test"\r\n' +
@@ -20,43 +42,26 @@ test('WebSocket basic auth', (t) => {
2042
return
2143
}
2244

23-
const key = req.headers['sec-websocket-key']
24-
const accept = crypto
25-
.createHash('sha1')
26-
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
27-
.digest('base64')
28-
29-
socket.write(
30-
'HTTP/1.1 101 Switching Protocols\r\n' +
31-
'Upgrade: websocket\r\n' +
32-
'Connection: Upgrade\r\n' +
33-
'Sec-WebSocket-Accept: ' + accept + '\r\n' +
34-
'\r\n'
35-
)
36-
37-
socket.on('data', () => socket.destroy())
38-
}).listen(0)
39-
40-
const { port } = server.address()
41-
const url = `ws://user:pass@127.0.0.1:${port}/path`
42-
43-
const ws = new WebSocket(url)
45+
if (attempts === 2) {
46+
t.assert.strictEqual(req.headers.authorization, expectedAuth)
47+
wss.handleUpgrade(req, socket, head, (websocket) => {
48+
wss.emit('connection', websocket, req)
49+
})
50+
return
51+
}
4452

45-
t.after(() => {
46-
ws.close()
47-
server.close()
53+
t.assert.fail(`unexpected upgrade attempt #${attempts}`)
4854
})
4955

50-
const promise = createDeferredPromise()
56+
server.listen(0, '127.0.0.1')
57+
await once(server, 'listening')
5158

52-
ws.addEventListener('open', () => {
53-
promise.resolve()
54-
ws.send('h')
55-
})
59+
const ws = new WebSocket(`ws://user:pass@127.0.0.1:${server.address().port}/path`)
5660

57-
ws.addEventListener('error', (e) => {
58-
promise.reject(e)
61+
await new Promise((resolve, reject) => {
62+
ws.addEventListener('open', resolve, { once: true })
63+
ws.addEventListener('error', ({ error }) => reject(error), { once: true })
5964
})
6065

61-
return promise.promise
66+
t.assert.strictEqual(attempts, 2)
6267
})

0 commit comments

Comments
 (0)