Skip to content
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

fix: support IPv6 during ClientRequest options parsing #489

Merged
merged 17 commits into from
Apr 26, 2024
34 changes: 31 additions & 3 deletions src/utils/getUrlByRequestOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,15 @@ it('resolves hostname to localhost if none provided', () => {
expect(getUrlByRequestOptions({}).hostname).toBe('localhost')
})

it('supports "hostname" instead of "host" and "port"', () => {
it('resolves host to localhost if none provided', () => {
expect(getUrlByRequestOptions({}).host).toBe('localhost')
})

it('supports "hostname" and "port"', () => {
const options: RequestOptions = {
protocol: 'https:',
hostname: '127.0.0.1:1234',
Copy link
Member

Choose a reason for hiding this comment

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

I'd be extremely careful around changes like this.

Yes, this is technically right—hostname does not include port. But in practice, I've seen a lot of libraries misuse Node.js. I fear this was one of those cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@kettanaito, can you give an example? Because it's invalid and Node throws an error immediately:

image

And this is working:
image

Copy link
Member

Choose a reason for hiding this comment

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

That's a good point! Let's remove this old logic and if any issues arise, tackle them separately. I have no notion of supporting hacky tools, and if you use http.request() incorrectly, you are hacky.

hostname: '127.0.0.1',
port: 1234,
path: '/resource',
}

Expand All @@ -117,14 +122,37 @@ it('supports "hostname" instead of "host" and "port"', () => {
)
})

it('handles IPv6 hostnames', () => {
it('use "hostname" if both "hostname" and "host" are specified', () => {
const options: RequestOptions = {
protocol: 'https:',
host: 'host',
hostname: 'hostname',
path: '/resource',
}

expect(getUrlByRequestOptions(options).href).toBe(
'https://hostname/resource'
)
})

it('parses "host" in IPv6', () => {
expect(
getUrlByRequestOptions({
host: '::1',
path: '/resource',
}).href
).toBe('http://[::1]/resource')

expect(
getUrlByRequestOptions({
host: '[::1]',
path: '/resource',
}).href
).toBe('http://[::1]/resource')

})

it('parses "host" and "port" in IPv6', () => {
expect(
getUrlByRequestOptions({
host: '::1',
Expand Down
52 changes: 14 additions & 38 deletions src/utils/getUrlByRequestOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type ResolvedRequestOptions = RequestOptions & RequestSelf

export const DEFAULT_PATH = '/'
const DEFAULT_PROTOCOL = 'http:'
const DEFAULT_HOST = 'localhost'
const DEFAULT_HOSTNAME = 'localhost'
const SSL_PORT = 443

function getAgent(
Expand Down Expand Up @@ -50,15 +50,6 @@ function getPortByRequestOptions(
return Number(options.port)
}

// Extract the port from the hostname.
if (options.hostname != null) {
Copy link
Member

Choose a reason for hiding this comment

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

Should this be options.host instead?

Copy link
Contributor Author

@mikicho mikicho Apr 21, 2024

Choose a reason for hiding this comment

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

host or hostname must not include port, so we can't infer the port from it.

Copy link
Member

Choose a reason for hiding this comment

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

🤔 What kind of hackery were we supporting with this.

Ditto, let's remove it as well. Fewer hacks, everyone wins.

const [, extractedPort] = options.hostname.match(/:(\d+)$/) || []

if (extractedPort != null) {
return Number(extractedPort)
}
}

// Otherwise, try to resolve port from the agent.
const agent = getAgent(options)

Expand All @@ -75,17 +66,6 @@ function getPortByRequestOptions(
return undefined
}

function getHostByRequestOptions(options: ResolvedRequestOptions): string {
const { hostname, host } = options

// If the hostname is specified, resolve the host from the "host:port" string.
if (hostname != null) {
return hostname.replace(/:\d+$/, '')
}

return host || DEFAULT_HOST
}

interface RequestAuth {
username: string
password: string
Expand All @@ -109,22 +89,20 @@ function isRawIPv6Address(host: string): boolean {
return host.includes(':') && !host.startsWith('[') && !host.endsWith(']')
}

function getHostname(host: string, port?: number): string {
const portString = typeof port !== 'undefined' ? `:${port}` : ''
function getHostname(options: ResolvedRequestOptions): string | undefined {
let host = options.hostname || options.host

/**
* @note As of Node >= 17, hosts (including "localhost") can resolve to IPv6
* addresses, so construct valid URL by surrounding the IPv6 host with brackets.
*/
if (isRawIPv6Address(host)) {
return `[${host}]${portString}`
}
if (host) {
if (isRawIPv6Address(host)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

host = `[${host}]`
}

if (typeof port === 'undefined') {
return host
// Check the presence of the port, and if it's present,
// remove it from the host, returning a hostname.
return new URL(`http://${host}`).hostname
}

return `${host}${portString}`
return DEFAULT_HOSTNAME
}

/**
Expand All @@ -146,13 +124,10 @@ export function getUrlByRequestOptions(options: ResolvedRequestOptions): URL {
const protocol = getProtocolByRequestOptions(options)
logger.info('protocol', protocol)

const host = getHostByRequestOptions(options)
logger.info('host', host)

const port = getPortByRequestOptions(options)
logger.info('port', port)

const hostname = getHostname(host, port)
const hostname = getHostname(options)
logger.info('hostname', hostname)

const path = options.path || DEFAULT_PATH
Expand All @@ -166,7 +141,8 @@ export function getUrlByRequestOptions(options: ResolvedRequestOptions): URL {
: ''
logger.info('auth string:', authString)

const url = new URL(`${protocol}//${hostname}${path}`)
const portString = typeof port !== 'undefined' ? `:${port}` : ''
const url = new URL(`${protocol}//${hostname}${portString}${path}`)
url.username = credentials?.username || ''
url.password = credentials?.password || ''

Expand Down
29 changes: 29 additions & 0 deletions test/modules/http/compliance/http-request-ipv6.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { it, expect, beforeAll, afterAll } from 'vitest'
import { httpGet } from '../../../helpers'
import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest'
import { DeferredPromise } from '@open-draft/deferred-promise'

const interceptor = new ClientRequestInterceptor()

beforeAll(() => {
interceptor.apply()
})

afterAll(() => {
interceptor.dispose()
})

it('supports requests with IPv6 request url', async () => {
const url = 'http://[2607:f0d0:1002:51::4]:8080/'
const listenerUrlPromise = new DeferredPromise<string>()

interceptor.on('request', ({ request }) => {
listenerUrlPromise.resolve(request.url)
request.respondWith(new Response('test'))
});

const { resBody } = await httpGet(url)
const requestUrl = await listenerUrlPromise
expect(resBody).toBe('test')
expect(requestUrl).toBe(url)
})
Loading