diff --git a/lib/internal/http.js b/lib/internal/http.js index 999ade29ed07..0a6f5f2e6948 100644 --- a/lib/internal/http.js +++ b/lib/internal/http.js @@ -19,7 +19,8 @@ const { const { URL } = require('internal/url'); const { Buffer } = require('buffer'); -const { isIPv4 } = require('internal/net'); +const { isIPv4, isIPv6 } = require('internal/net'); +const { canonicalizeIP } = internalBinding('cares_wrap'); const { ERR_PROXY_INVALID_CONFIG } = require('internal/errors').codes; let utcCache; let utcHeaderCache; @@ -75,6 +76,30 @@ function ipToInt(ip) { return result >>> 0; } +function normalizeProxyHost(hostname) { + let host = hostname.toLowerCase(); + + if (host[0] === '[' && host[host.length - 1] === ']') { + host = host.slice(1, -1); + } + + if (host.endsWith('.')) { + host = host.slice(0, -1); + } + + if (isIPv6(host)) { + host = canonicalizeIP(host); + if (host.startsWith('::ffff:')) { + const ipv4 = host.slice(7); + if (isIPv4(ipv4)) { + host = ipv4; + } + } + } + + return host; +} + // There are two factors in play when proxying the request: // 1. What the request protocol is, that is, whether users are sending it via // http.request or https.request, or whether they are sending @@ -137,7 +162,7 @@ class ProxyConfig { this.auth = `Basic ${Buffer.from(auth).toString('base64')}`; } if (noProxyList) { - this.bypassList = noProxyList.split(',').map((entry) => entry.trim().toLowerCase()); + this.bypassList = noProxyList.split(',').map((entry) => normalizeProxyHost(entry.trim())); } else { this.bypassList = []; // No bypass list provided. } @@ -159,7 +184,7 @@ class ProxyConfig { return true; // No bypass list, always use the proxy. } - const host = hostname.toLowerCase(); + const host = normalizeProxyHost(hostname); const hostWithPort = port ? `${host}:${port}` : host; for (let i = 0; i < bypassList.length; i++) { diff --git a/test/client-proxy/test-http-proxy-request-no-proxy-loopback.mjs b/test/client-proxy/test-http-proxy-request-no-proxy-loopback.mjs new file mode 100644 index 000000000000..f8eb3967fe45 --- /dev/null +++ b/test/client-proxy/test-http-proxy-request-no-proxy-loopback.mjs @@ -0,0 +1,86 @@ +// This tests that NO_PROXY matches alternate loopback host spellings. + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import { once } from 'events'; +import http from 'node:http'; +import { runProxiedRequest } from '../common/proxy-server.js'; + +if (!common.hasIPv6) { + common.skip('missing IPv6 support'); +} + +const server4 = http.createServer(common.mustCall((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello World\n'); +}, 2)); +server4.listen(0, '127.0.0.1'); +await once(server4, 'listening'); + +const server6 = http.createServer(common.mustCall((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello World\n'); +}, 1)); +server6.listen(0, '::1'); +await once(server6, 'listening'); + +// Start a proxy server that should NOT be used. +const proxy = http.createServer(); +proxy.on('request', common.mustNotCall()); +proxy.on('connect', common.mustNotCall()); +proxy.listen(0); +await once(proxy, 'listening'); + +// A trailing dot in a DNS name should match the dotless NO_PROXY entry. +{ + const { code, signal, stderr, stdout } = await runProxiedRequest({ + NODE_USE_ENV_PROXY: 1, + REQUEST_URL: `http://localhost.:${server4.address().port}/test`, + HTTP_PROXY: `http://localhost:${proxy.address().port}`, + NO_PROXY: 'localhost', + RESOLVE_TO_LOCALHOST: 'localhost.', + }); + + assert.match(stdout, /Status Code: 200/); + assert.match(stdout, /Hello World/); + assert.match(stdout, /Resolving lookup for localhost\./); + assert.strictEqual(stderr.trim(), ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); +} + +// Bracketed IPv6 URL hosts should match unbracketed NO_PROXY entries. +{ + const { code, signal, stderr, stdout } = await runProxiedRequest({ + NODE_USE_ENV_PROXY: 1, + REQUEST_URL: `http://[::1]:${server6.address().port}/test`, + HTTP_PROXY: `http://localhost:${proxy.address().port}`, + NO_PROXY: '::1', + }); + + assert.match(stdout, /Status Code: 200/); + assert.match(stdout, /Hello World/); + assert.strictEqual(stderr.trim(), ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); +} + +// IPv4-mapped IPv6 loopback should match its IPv4 NO_PROXY entry. +{ + const { code, signal, stderr, stdout } = await runProxiedRequest({ + NODE_USE_ENV_PROXY: 1, + REQUEST_URL: `http://[::ffff:7f00:1]:${server4.address().port}/test`, + HTTP_PROXY: `http://localhost:${proxy.address().port}`, + NO_PROXY: 'localhost,127.0.0.1,::1', + }); + + assert.match(stdout, /Status Code: 200/); + assert.match(stdout, /Hello World/); + assert.strictEqual(stderr.trim(), ''); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); +} + +proxy.close(); +server6.close(); +server4.close();