-
-
Notifications
You must be signed in to change notification settings - Fork 759
Resolve .localhost to loopback per Fetch spec #4456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,61 @@ const SessionCache = class WeakSessionCache { | |
| } | ||
| } | ||
|
|
||
| // Implements the relevant part of "resolve an origin" for localhost public suffix | ||
| // https://fetch.spec.whatwg.org/#resolve-an-origin | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is fetch specific code and should not be in core
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am sure this can be more performant implemented
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = [ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you instantiate this array on every call?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') | ||
|
|
@@ -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, | ||
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have to check what this lookup attribute is doing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
@@ -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 | ||
|
|
||
| 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) | ||
| } | ||
| }) | ||
| }) |
| 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) | ||
| } | ||
| }) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
Thats the one this addresses