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

Add support for HTTPS over HTTP on Node.js #5037

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Expand Up @@ -473,7 +473,9 @@ These are the available config options for making requests. Only the `url` is re
// supplies credentials.
// This will set an `Proxy-Authorization` header, overwriting any existing
// `Proxy-Authorization` custom headers you have set using `headers`.
// If the proxy server uses HTTPS, then you must set the protocol to `https`.
// `protocol` should be set to the protocol of the proxy (not the upstream server).
// Note: HTTPS over an HTTP proxy is only supported on Node.js and cannot be used
// together with `maxRedirects` (`maxRedirects` will be automatically set to 0).
proxy: {
protocol: 'https',
host: '127.0.0.1',
Expand Down
145 changes: 118 additions & 27 deletions lib/adapters/http.js
Expand Up @@ -19,6 +19,7 @@ import stream from 'stream';
import AxiosHeaders from '../core/AxiosHeaders.js';
import AxiosTransformStream from '../helpers/AxiosTransformStream.js';
import EventEmitter from 'events';
import * as tls from 'tls';

const isBrotliSupported = utils.isFunction(zlib.createBrotliDecompress);

Expand Down Expand Up @@ -47,6 +48,56 @@ function dispatchBeforeRedirect(options) {
}
}

/**
* Provides an HTTP tunnel socket via an HTTP CONNECT request.
* The tunnel socket is used for sending HTTPS requests via an HTTP proxy.
Copy link

@Nevon Nevon Oct 10, 2022

Choose a reason for hiding this comment

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

HTTP requests can be made via an HTTP proxy using the CONNECT method as well. Shouldn't this only create the TlsSocket in case the destination is using HTTPS, and otherwise return the original socket that was used to make the CONNECT request?

EDIT: Oh I see further down that this is only used when the target is HTTPS. So for an HTTP proxy with a target that's not using TLS, it assumes it's not gonna use the connect method, which is quite reasonable for an HTTP client.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Nevon exactly, CONNECT is only used if the destination uses TLS. I figured it would not make that much sense for non-TLS destinations because of the extra round trip.

* @param {http.ClientRequestArgs} options
* @param {AxiosProxyConfig} proxyConfig
* @param {number} connectTimeout
* @returns {Promise<http.Socket>}
*/
const getHttpTunnelSocket = (options, proxyConfig, { connectTimeout }) => {
let success, fail;
const promise = new Promise((resolve, reject) => [success, fail] = [resolve, reject]);

let destHost = `${options.hostname || options.host}:${options.port || 443}`;
const headers = {
"Host": destHost
};
if (proxyConfig.auth) {
headers['Proxy-Authorization'] = getProxyAuthHeaderValue(proxyConfig.auth);
}

const proxyReq = http.request({
method: 'CONNECT',
path: destHost,
host: proxyConfig.hostname || proxyConfig.host,
port: proxyConfig.port,
agent: false,
headers
});
proxyReq.setTimeout(connectTimeout, () => fail(new Error(`proxy CONNECT: timeout after ${connectTimeout}ms`)));
proxyReq.end();
proxyReq.on('error', err => fail(err));
proxyReq.on('connect', (response, socket) => {
const statusOK = response.statusCode === 200;
if (!statusOK) return void fail(new Error(`proxy CONNECT: status code ${response.statusCode}`));

const httpsAgent = options.agents && options.agents.https || https.globalAgent;
const tlsSocket = tls.connect({
servername: options.headers.Host || options.hostname || options.host,
socket: socket,
rejectUnauthorized: httpsAgent.options.rejectUnauthorized,
});
tlsSocket.on('secureConnect', () => {
success(tlsSocket);
});
tlsSocket.on("error", err => fail(new Error(`secureConnect: ${err.message}`)));
});

return promise;
};

/**
* If the proxy or config afterRedirects functions are defined, call them with the options
*
Expand All @@ -57,13 +108,7 @@ function dispatchBeforeRedirect(options) {
* @returns {http.ClientRequestArgs}
*/
function setProxy(options, configProxy, location) {
let proxy = configProxy;
if (!proxy && proxy !== false) {
const proxyUrl = getProxyForUrl(location);
if (proxyUrl) {
proxy = new URL(proxyUrl);
}
}
const proxy = resolveProxy(configProxy, location);
if (proxy) {
// Basic proxy authorization
if (proxy.username) {
Expand All @@ -75,12 +120,14 @@ function setProxy(options, configProxy, location) {
if (proxy.auth.username || proxy.auth.password) {
proxy.auth = (proxy.auth.username || '') + ':' + (proxy.auth.password || '');
}
const base64 = Buffer
.from(proxy.auth, 'utf8')
.toString('base64');
options.headers['Proxy-Authorization'] = 'Basic ' + base64;
}

options._httpsOverHttp = proxy.protocol === "http:" && options.protocol === "https:";
Copy link
Contributor

@mbargiel mbargiel Jan 31, 2023

Choose a reason for hiding this comment

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

What about https over https? You still need a tunnel in that case because you need end-to-end encryption with the intended recipient.

Just for reference, here's how I fixed HTTP CONNECT support for secure HTTPS proxying. My solution relies on the underlying agents (I mentioned it here but I found it a bit too hacky and it required introducing an unmaintained dependency, which I wasn't comfortable with).

You could also check out my fork directly for ideas : mbargiel/axios@e6f9026...feature/fix-proxy/http-connect

Copy link
Contributor

Choose a reason for hiding this comment

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

The nice thing with agents is that you don't have to worry about problems with follow-redirects: the tunnel is established in a lower abstraction layer, and once Axios has the socket, it's established properly to the end server. Redirects work over the tunnel.

if (options._httpsOverHttp) return proxy;

if (proxy.auth) {
options.headers['Proxy-Authorization'] = getProxyAuthHeaderValue(proxy.auth);
}
options.headers.host = options.hostname + (options.port ? ':' + options.port : '');
options.hostname = proxy.hostname;
// Replace 'host' since options is not a URL object
Expand All @@ -97,6 +144,24 @@ function setProxy(options, configProxy, location) {
// the exact same logic as if the redirected request was performed by axios directly.
setProxy(redirectOptions, configProxy, redirectOptions.href);
};

return proxy;
}

function getProxyAuthHeaderValue(auth) {
return 'Basic ' + Buffer
.from(auth, 'utf8')
.toString('base64');
}

function resolveProxy(configProxy, location) {
if (!configProxy && configProxy !== false) {
const proxyUrl = getProxyForUrl(location);
if (proxyUrl) {
configProxy = new URL(proxyUrl);
}
}
return configProxy;
}

/*eslint consistent-return:0*/
Expand Down Expand Up @@ -330,12 +395,47 @@ export default function httpAdapter(config) {
beforeRedirects: {}
};

let proxy;
if (config.socketPath) {
options.socketPath = config.socketPath;
} else {
options.hostname = parsed.hostname;
options.port = parsed.port;
setProxy(options, config.proxy, protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path);
proxy = setProxy(options, config.proxy, protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path);
}

let timeout;
if (config.timeout) {
// This is forcing a int timeout to avoid problems if the `req` interface doesn't handle other types.
timeout = parseInt(config.timeout, 10);

if (isNaN(timeout)) {
reject(new AxiosError(
'error trying to parse `config.timeout` to int',
AxiosError.ERR_BAD_OPTION_VALUE,
config
));

return;
}
}

// Issue a CONNECT request first if proxying an HTTPS request over HTTP
if (options._httpsOverHttp) {
// Redirects are currently not supported with HTTPS over HTTP
// Need to find a solution for connection resets after redirect responses
config.maxRedirects = 0;

const createConnection = (opts, cb) => {
getHttpTunnelSocket(options, proxy, { connectTimeout: timeout || 10000 })
.then(socket => cb(null, socket))
.catch(cb);
};
if (options.agents.https) {
options.agents.https.createConnection = createConnection;
} else {
options.createConnection = createConnection;
}
}

let transport;
Expand Down Expand Up @@ -510,6 +610,11 @@ export default function httpAdapter(config) {

// Handle errors
req.on('error', function handleRequestError(err) {
// The proxy may already have closed the connection with the upstream server if keep-alive is disabled.
// This can lead to a connection reset when the client sends a TLS close_notify message to the proxy.
// Note: This hack would not work with follow-redirects, which only emits the final response.
if (options._httpsOverHttp && err.code === "ECONNRESET" && req.res && req.res.complete) return;

// @todo remove
// if (req.aborted && err.code !== AxiosError.ERR_FR_TOO_MANY_REDIRECTS) return;
reject(AxiosError.from(err, null, config, req));
Expand All @@ -522,21 +627,7 @@ export default function httpAdapter(config) {
});

// Handle request timeout
if (config.timeout) {
// This is forcing a int timeout to avoid problems if the `req` interface doesn't handle other types.
const timeout = parseInt(config.timeout, 10);

if (isNaN(timeout)) {
reject(new AxiosError(
'error trying to parse `config.timeout` to int',
AxiosError.ERR_BAD_OPTION_VALUE,
config,
req
));

return;
}

if (timeout) {
// Sometime, the response will be very slow, and does not respond, the connect event will be block by event loop system.
// And timer callback will be fired, and abort() will be invoked before connection, then get "socket hang up" and code ECONNRESET.
// At this time, if we have a large number of request, nodejs will hang up some socket on background. and the number will up and up.
Expand Down
51 changes: 51 additions & 0 deletions test/unit/adapters/http.js
Expand Up @@ -1037,6 +1037,57 @@ describe('supports http with nodejs', function () {
});
});

it('should support HTTPS requests over an HTTP proxy', done => {
process.env.https_proxy = "http://localhost:4000";

const httpsOptions = {
key: fs.readFileSync(path.join(__dirname, 'key.pem')),
cert: fs.readFileSync(path.join(__dirname, 'cert.pem'))
};
server = https.createServer(httpsOptions, function (req, res) {
res.setHeader("Connection", "Keep-Alive");
res.end('HTTPS response');
});
server.listen(4444, () => {
proxy = http.createServer();
proxy.on("connect", (req, clientSocket) => {
try {
assert.equal(req.url, "localhost:4444");
assert.equal(req.headers.host, "localhost:4444");
} catch (err) {
done(err);
}

const proxySocket = new net.Socket();
proxySocket.connect(4444);
proxySocket.on("connect", () => {
clientSocket.write("HTTP/1.1 200 Connection Established\n\n");
proxySocket.pipe(clientSocket);
clientSocket.pipe(proxySocket);
});
});

proxy.listen(4000, async () => {
try {
const httpsAgent = new https.Agent({
rejectUnauthorized: false,

Check failure

Code scanning / CodeQL

Disabling certificate validation

Disabling certificate validation is strongly discouraged.
keepAlive: true,
});

const res = await axios.get('https://localhost:4444/', { httpsAgent, maxRedirects: 0 });
assert.equal(res.status, 200);
assert.equal(res.data, "HTTPS response");

const res2 = await axios.get('https://localhost:4444/', { httpsAgent, maxRedirects: 0 });
assert.equal(res2.data, "HTTPS response");
done();
} catch (err) {
done(err);
}
});
});
});

it('should not use proxy for domains in no_proxy', function (done) {
server = http.createServer(function (req, res) {
res.setHeader('Content-Type', 'text/html; charset=UTF-8');
Expand Down