Skip to content

Commit 6695067

Browse files
joyeecheungtargos
authored andcommitted
http,https: handle IPv6 with proxies
This simplifies the proxy configuration handling code, adds tests to make sure the proxy support works with IPv6 and throws correct errors for invalid proxy IPs. Drive-by: remove useless properties from ProxyConfig PR-URL: #59894 Refs: #57872 Reviewed-By: Aditi Singh <aditisingh1400@gmail.com> Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
1 parent b970c0b commit 6695067

6 files changed

Lines changed: 208 additions & 21 deletions

File tree

‎lib/https.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ let debug = require('internal/util/debuglog').debuglog('https', (fn) => {
6868
constnet=require('net');
6969
const{URL, urlToHttpOptions, isURL }=require('internal/url');
7070
const{ validateObject }=require('internal/validators');
71-
const{ isIP, isIPv6}=require('internal/net');
71+
const{ isIP }=require('internal/net');
7272
constassert=require('internal/assert');
7373
const{ getOptionValue }=require('internal/options');
7474

@@ -171,7 +171,11 @@ function getTunnelConfigForProxiedHttps(agent, reqOptions) {
171171
}
172172
const{ auth, href }=agent[kProxyConfig];
173173
// The request is a HTTPS request, assemble the payload for establishing the tunnel.
174-
constrequestHost=isIPv6(reqOptions.host) ? `[${reqOptions.host}]` : reqOptions.host;
174+
constipType=isIP(reqOptions.host);
175+
// The request target must put IPv6 address in square brackets.
176+
// Here reqOptions is already processed by urlToHttpOptions so we'll add them back if necessary.
177+
// See https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
178+
constrequestHost=ipType===6 ? `[${reqOptions.host}]` : reqOptions.host;
175179
constrequestPort=reqOptions.port||agent.defaultPort;
176180
constendpoint=`${requestHost}:${requestPort}`;
177181
// The ClientRequest constructor should already have validated the host and the port.
@@ -198,7 +202,7 @@ function getTunnelConfigForProxiedHttps(agent, reqOptions) {
198202
proxyTunnelPayload: payload,
199203
requestOptions: {// Options used for the request sent after the tunnel is established.
200204
__proto__: null,
201-
servername: reqOptions.servername||(isIP(reqOptions.host) ? undefined : reqOptions.host),
205+
servername: reqOptions.servername||ipType ? undefined : reqOptions.host,
202206
...reqOptions,
203207
},
204208
};

‎lib/internal/http.js‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const{
44
Date,
5+
Number,
56
NumberParseInt,
67
Symbol,
78
decodeURIComponent,
@@ -99,24 +100,23 @@ function ipToInt(ip) {
99100
* Represents the proxy configuration for an agent. The built-in http and https agent
100101
* implementation have one of this when they are configured to use a proxy.
101102
* @property {string} href - Full URL of the proxy server.
102-
* @property {string} host - Full host including port, e.g. 'localhost:8080'.
103-
* @property {string} hostname - Hostname without brackets for IPv6 addresses.
104-
* @property {number} port - Port number of the proxy server.
105-
* @property {string} protocol - Protocol of the proxy server, e.g. 'http:' or 'https:'.
103+
* @property {string} protocol - Proxy protocol used to talk to the proxy server.
106104
* @property {string|undefined} auth - proxy-authorization header value, if username or password is provided.
107105
* @property {Array<string>} bypassList - List of hosts to bypass the proxy.
108106
* @property {object} proxyConnectionOptions - Options for connecting to the proxy server.
109107
*/
110108
classProxyConfig{
111109
constructor(proxyUrl,keepAlive,noProxyList){
112-
const{ host, hostname, port, protocol, username, password }=newURL(proxyUrl);
113-
this.href=proxyUrl;// Full URL of the proxy server.
114-
this.host=host;// Full host including port, e.g. 'localhost:8080'.
115-
// Trim off the brackets from IPv6 addresses. As it's parsed from a valid URL, an opening
116-
// "[" Must already have a matching "]" at the end.
117-
this.hostname=hostname[0]==='[' ? hostname.slice(1,-1) : hostname;
118-
this.port=port ? NumberParseInt(port,10) : (protocol==='https:' ? 443 : 80);
119-
this.protocol=protocol;// Protocol of the proxy server, e.g. 'http:' or 'https:'.
110+
letparsedURL;
111+
try{
112+
parsedURL=newURL(proxyUrl);
113+
}catch{
114+
thrownewERR_PROXY_INVALID_CONFIG(`Invalid proxy URL: ${proxyUrl}`);
115+
}
116+
const{ hostname, port, protocol, username, password }=parsedURL;
117+
118+
this.href=proxyUrl;
119+
this.protocol=protocol;
120120

121121
if(username||password){
122122
// If username or password is provided, prepare the proxy-authorization header.
@@ -128,9 +128,13 @@ class ProxyConfig {
128128
}else{
129129
this.bypassList=[];// No bypass list provided.
130130
}
131+
131132
this.proxyConnectionOptions={
132-
host: this.hostname,
133-
port: this.port,
133+
// The host name comes from parsed URL so if it starts with '[' it must be an IPv6 address
134+
// ending with ']'. Remove the brackets for net.connect().
135+
host: hostname[0]==='[' ? hostname.slice(1,-1) : hostname,
136+
// The port comes from parsed URL so it is either '' or a valid number string.
137+
port: port ? Number(port) : (protocol==='https:' ? 443 : 80),
134138
};
135139
}
136140

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// This tests that constructing agents with invalid proxy URLs throws ERR_PROXY_INVALID_CONFIG.
2+
import'../common/index.mjs';
3+
importassertfrom'node:assert';
4+
importhttpfrom'node:http';
5+
6+
consttestCases=[
7+
{
8+
name: 'invalid IPv4 address',
9+
proxyUrl: 'http://256.256.256.256:8080',
10+
},
11+
{
12+
name: 'invalid IPv6 address',
13+
proxyUrl: 'http://::1:8080',
14+
},
15+
{
16+
name: 'missing host',
17+
proxyUrl: 'http://:8080',
18+
},
19+
{
20+
name: 'non-numeric port',
21+
proxyUrl: 'http://proxy.example.com:port',
22+
},
23+
];
24+
25+
for(consttestCaseoftestCases){
26+
assert.throws(()=>{
27+
newhttp.Agent({
28+
proxyEnv: {
29+
HTTP_PROXY: testCase.proxyUrl,
30+
},
31+
});
32+
},{
33+
code: 'ERR_PROXY_INVALID_CONFIG',
34+
message: `Invalid proxy URL: ${testCase.proxyUrl}`,
35+
});
36+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// This tests making HTTP requests through an HTTP proxy using IPv6 addresses.
2+
3+
import*ascommonfrom'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
importhttpfrom'node:http';
6+
import{once}from'events';
7+
import{createProxyServer,runProxiedRequest}from'../common/proxy-server.js';
8+
9+
if(!common.hasIPv6){
10+
common.skip('missing IPv6 support');
11+
}
12+
13+
// Start a server to process the final request.
14+
constserver=http.createServer(common.mustCall((req,res)=>{
15+
res.end('Hello world');
16+
}));
17+
server.on('error',common.mustNotCall((err)=>{console.error('Server error',err);}));
18+
server.listen(0);
19+
awaitonce(server,'listening');
20+
21+
// Start a minimal proxy server.
22+
const{ proxy, logs }=createProxyServer();
23+
proxy.listen(0);
24+
awaitonce(proxy,'listening');
25+
26+
{
27+
constserverHost=`[::1]:${server.address().port}`;
28+
constrequestUrl=`http://${serverHost}/test`;
29+
constexpectedLogs=[{
30+
method: 'GET',
31+
url: requestUrl,
32+
headers: {
33+
'connection': 'keep-alive',
34+
'proxy-connection': 'keep-alive',
35+
'host': serverHost,
36+
},
37+
}];
38+
39+
const{ code, signal, stdout }=awaitrunProxiedRequest({
40+
NODE_USE_ENV_PROXY: 1,
41+
REQUEST_URL: requestUrl,
42+
HTTP_PROXY: `http://[::1]:${proxy.address().port}`,
43+
});
44+
assert.deepStrictEqual(logs,expectedLogs);
45+
assert.match(stdout,/Helloworld/);
46+
assert.strictEqual(code,0);
47+
assert.strictEqual(signal,null);
48+
}
49+
50+
proxy.close();
51+
server.close();
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// This tests making HTTPS requests through an HTTP proxy using IPv6 addresses.
2+
3+
import*ascommonfrom'../common/index.mjs';
4+
importfixturesfrom'../common/fixtures.js';
5+
importassertfrom'node:assert';
6+
import{once}from'events';
7+
import{createProxyServer,runProxiedRequest}from'../common/proxy-server.js';
8+
9+
if(!common.hasIPv6){
10+
common.skip('missing IPv6 support');
11+
}
12+
13+
if(!common.hasCrypto){
14+
common.skip('missing crypto');
15+
}
16+
17+
// https must be dynamically imported so that builds without crypto support
18+
// can skip it.
19+
const{default: https}=awaitimport('node:https');
20+
21+
// Start a server to process the final request.
22+
constserver=https.createServer({
23+
cert: fixtures.readKey('agent8-cert.pem'),
24+
key: fixtures.readKey('agent8-key.pem'),
25+
},common.mustCall((req,res)=>{
26+
res.end('Hello world');
27+
},2));
28+
server.on('error',common.mustNotCall((err)=>{console.error('Server error',err);}));
29+
server.listen(0);
30+
awaitonce(server,'listening');
31+
32+
// Start a minimal proxy server.
33+
const{ proxy, logs }=createProxyServer();
34+
proxy.listen(0);
35+
awaitonce(proxy,'listening');
36+
37+
{
38+
constserverHost=`localhost:${server.address().port}`;
39+
constrequestUrl=`https://${serverHost}/test`;
40+
constexpectedLogs=[{
41+
method: 'CONNECT',
42+
url: serverHost,
43+
headers: {
44+
'proxy-connection': 'keep-alive',
45+
'host': serverHost,
46+
},
47+
}];
48+
49+
const{ code, signal, stdout }=awaitrunProxiedRequest({
50+
NODE_USE_ENV_PROXY: 1,
51+
REQUEST_URL: requestUrl,
52+
HTTPS_PROXY: `http://[::1]:${proxy.address().port}`,
53+
NODE_EXTRA_CA_CERTS: fixtures.path('keys','fake-startcom-root-cert.pem'),
54+
});
55+
assert.deepStrictEqual(logs,expectedLogs);
56+
assert.match(stdout,/Helloworld/);
57+
assert.strictEqual(code,0);
58+
assert.strictEqual(signal,null);
59+
}
60+
61+
// Test with IPv6 address in the request URL.
62+
{
63+
logs.splice(0,logs.length);// Clear the logs.
64+
constserverHost=`[::1]:${server.address().port}`;
65+
constrequestUrl=`https://${serverHost}/test`;
66+
constexpectedLogs=[{
67+
method: 'CONNECT',
68+
url: serverHost,
69+
headers: {
70+
'proxy-connection': 'keep-alive',
71+
'host': serverHost,
72+
},
73+
}];
74+
75+
const{ code, signal, stdout }=awaitrunProxiedRequest({
76+
NODE_USE_ENV_PROXY: 1,
77+
REQUEST_URL: requestUrl,
78+
HTTPS_PROXY: `http://[::1]:${proxy.address().port}`,
79+
// Disable certificate verification for this request, for we don't have
80+
// a certificate for [::1].
81+
NODE_TLS_REJECT_UNAUTHORIZED: '0',
82+
});
83+
assert.deepStrictEqual(logs,expectedLogs);
84+
assert.match(stdout,/Helloworld/);
85+
assert.strictEqual(code,0);
86+
assert.strictEqual(signal,null);
87+
}
88+
89+
proxy.close();
90+
server.close();

‎test/common/proxy-server.js‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ exports.createProxyServer = function(options = {}) {
3232
}
3333
proxy.on('request',(req,res)=>{
3434
logRequest(logs,req);
35-
const[hostname,port]=req.headers.host.split(':');
35+
const{hostname, port}=newURL(`http://${req.headers.host}`);
3636
consttargetPort=port||80;
3737

3838
consturl=newURL(req.url);
3939
constoptions={
40-
hostname: hostname,
40+
hostname: hostname.startsWith('[') ? hostname.slice(1,-1) : hostname,
4141
port: targetPort,
4242
path: url.pathname+url.search,// Convert back to relative URL.
4343
method: req.method,
@@ -72,13 +72,15 @@ exports.createProxyServer = function(options = {}) {
7272
proxy.on('connect',(req,res,head)=>{
7373
logRequest(logs,req);
7474

75-
const[hostname,port]=req.url.split(':');
75+
const{hostname, port}=newURL(`https://${req.url}`);
7676

7777
res.on('error',(err)=>{
7878
logs.push({error: err,source: 'client response for connect'});
7979
});
8080

81-
constproxyReq=net.connect(port,hostname,()=>{
81+
constnormalizedHostname=hostname.startsWith('[')&&hostname.endsWith(']') ?
82+
hostname.slice(1,-1) : hostname;
83+
constproxyReq=net.connect(port,normalizedHostname,()=>{
8284
res.write(
8385
'HTTP/1.1 200 Connection Established\r\n'+
8486
'Proxy-agent: Node.js-Proxy\r\n'+

0 commit comments

Comments
 (0)