Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 73 additions & 4 deletions lib/core/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,61 @@ const SessionCache = class WeakSessionCache {
}
}

// Implements the relevant part of "resolve an origin" for localhost public suffix
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the relevant part? What parts are messing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the specs there are 4 different cases for origin resolution; only one has to do with localhost

If origin’s host’s public suffix is "localhost" or "localhost.", then return « ::1, 127.0.0.1 ».

Thats the one this addresses

// https://fetch.spec.whatwg.org/#resolve-an-origin
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fetch specific code and should not be in core

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll find a new home for it. Any suggestions?

function isLocalhostPublicSuffix (hostname) {
if (!hostname || typeof hostname !== 'string') {
return false
}

// Normalize trailing dot and casing
let h = hostname.toLowerCase()
if (h[h.length - 1] === '.') {
h = h.slice(0, -1)
}

// Exact localhost or any subdomain of .localhost
return h === 'localhost' || h.endsWith('.localhost')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am sure this can be more performant implemented

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll see what I can do

}

// https://nodejs.org/api/dns.html#dnslookuphostname-options-callback
// Custom DNS lookup that resolves any *.localhost name to loopback addresses
// Returns both ::1 and 127.0.0.1 when opts.all is true, otherwise
// respects requested family, defaulting to IPv4 for maximal compatibility.
function localhostLookup (_host, opts, cb) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}

const all = !!(opts && opts.all)
const family = opts && opts.family
const ipv4 = '127.0.0.1'
const ipv6 = '::1'

const results = [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you instantiate this array on every call?

Copy link
Copy Markdown
Contributor Author

@FelixVaughan FelixVaughan Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could hoist out of the function but I think that would sacrifice readability for minor optimizations

{ address: ipv6, family: 6 },
{ address: ipv4, family: 4 }
]

if (all) {
cb(null, results)
return
}

if (family === 6) {
cb(null, ipv6, 6)
return
}
if (family === 4) {
cb(null, ipv4, 4)
return
}

// Default to IPv4 to avoid environments without IPv6
cb(null, ipv4, 4)
}

function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
Expand All @@ -67,7 +122,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess

port = port || 443

socket = tls.connect({
const tlsOptions = {
highWaterMark: 16384, // TLS in node can't have bigger HWM anyway...
...options,
servername,
Expand All @@ -77,7 +132,14 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
socket: httpSocket, // upgrade socket connection
port,
host: hostname
})
}

// Spec: If public suffix is localhost, resolve to loopback
if (isLocalhostPublicSuffix(hostname)) {
tlsOptions.lookup = localhostLookup
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have to check what this lookup attribute is doing

Copy link
Copy Markdown
Contributor Author

@FelixVaughan FelixVaughan Aug 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its purpose is to resolve a hostname. By default it performs a dns lookup but here we override it to short-circuit the lookup. Here's a link to the docs

}

socket = tls.connect(tlsOptions)

socket
.on('session', function (session) {
Expand All @@ -89,13 +151,20 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess

port = port || 80

socket = net.connect({
const netOptions = {
highWaterMark: 64 * 1024, // Same as nodejs fs streams.
...options,
localAddress,
port,
host: hostname
})
}

// Spec: If public suffix is localhost, resolve to loopback
if (isLocalhostPublicSuffix(hostname)) {
netOptions.lookup = localhostLookup
}

socket = net.connect(netOptions)
}

// Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket
Expand Down
74 changes: 74 additions & 0 deletions test/client-localhost-resolution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use strict'

const { test } = require('node:test')
const assert = require('node:assert')
const http = require('node:http')
const { Client } = require('..')
const diagnosticsChannel = require('node:diagnostics_channel')

async function withServer (handler, fn) {
const server = http.createServer(handler)
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
try {
const { port } = server.address()
return await fn({ port })
} finally {
await new Promise((resolve) => server.close(resolve))
}
}

test('core Client resolves subdomains of localhost to loopback', async () => {
await withServer((req, res) => {
res.statusCode = 200
res.setHeader('content-type', 'text/plain')
res.end('ok')
}, async ({ port }) => {
const urls = [
`http://sub.localhost:${port}`,
`http://sub.localhost.:${port}`,
`http://a.b.localhost:${port}`
]

const connected = diagnosticsChannel.channel('undici:client:connected')
const seen = []
const allowedHosts = new Set(['sub.localhost', 'sub.localhost.', 'a.b.localhost'])
const onConnected = (evt) => {
const { hostname } = evt.connectParams
if (allowedHosts.has(hostname)) {
seen.push({ hostname, address: evt.socket.remoteAddress })
}
}
connected.subscribe(onConnected)
try {
for (const origin of urls) {
const client = new Client(origin)
await new Promise((resolve, reject) => {
client.request({ path: '/', method: 'GET' }, (err, data) => {
if (err) return reject(err)
let body = ''
data.body.on('data', (chunk) => { body += String(chunk) })
data.body.on('end', () => {
try {
assert.strictEqual(data.statusCode, 200)
assert.strictEqual(body, 'ok')
client.close(() => resolve())
} catch (e) {
reject(e)
}
})
})
})
}

assert.strictEqual(seen.length, 3)
for (const { address } of seen) {
assert.ok(
address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1',
`expected loopback, got ${address}`
)
}
} finally {
connected.unsubscribe(onConnected)
}
})
})
57 changes: 57 additions & 0 deletions test/fetch/localhost-resolution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use strict'

const { test } = require('node:test')
const assert = require('node:assert')
const http = require('node:http')
const { fetch } = require('../..')
const diagnosticsChannel = require('node:diagnostics_channel')

async function withServer (handler, fn) {
const server = http.createServer(handler)
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
try {
const { port } = server.address()
return await fn({ port })
} finally {
await new Promise((resolve) => server.close(resolve))
}
}

test('fetch resolves subdomains of localhost to loopback', async (t) => {
const connected = diagnosticsChannel.channel('undici:client:connected')
const seen = []
const onConnected = (evt) => {
seen.push({ hostname: evt.connectParams.hostname, address: evt.socket.remoteAddress })
}
connected.subscribe(onConnected)
try {
await withServer((req, res) => {
res.statusCode = 200
res.setHeader('content-type', 'text/plain')
res.end('ok')
}, async ({ port }) => {
const urls = [
`http://sub.localhost:${port}/`,
`http://sub.localhost.:${port}/`,
`http://a.b.localhost:${port}/`
]

for (const url of urls) {
const resp = await fetch(url)
assert.strictEqual(resp.ok, true)
assert.strictEqual(resp.status, 200)
assert.strictEqual(await resp.text(), 'ok')
}

assert.strictEqual(seen.length, 3)
for (const { address } of seen) {
assert.ok(
address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1',
`expected loopback, got ${address}`
)
}
})
} finally {
connected.unsubscribe(onConnected)
}
})
Loading