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
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 |
---|---|---|
|
@@ -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); | ||
|
||
|
@@ -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. | ||
* @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 | ||
* | ||
|
@@ -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) { | ||
|
@@ -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:"; | ||
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. 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 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. 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 : ''); | ||
const proxyHost = proxy.hostname || proxy.host; | ||
options.hostname = proxyHost; | ||
|
@@ -98,6 +145,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; | ||
} | ||
|
||
const isHttpAdapterSupported = typeof process !== 'undefined' && utils.kindOf(process) === 'process'; | ||
|
@@ -333,12 +398,47 @@ export default isHttpAdapterSupported && 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; | ||
|
@@ -513,6 +613,11 @@ export default isHttpAdapterSupported && 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)); | ||
|
@@ -525,21 +630,7 @@ export default isHttpAdapterSupported && 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. | ||
|
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.
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.
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.
@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.