mirror of
https://github.com/cypress-io/cypress.git
synced 2026-04-29 11:32:15 -05:00
Fix proxying HTTPS requests to IP addresses (#4947)
* use own server-destroy implementation that supports secureConnect events * stand up HTTPS server for requests over ssl to IPs * don't need to resolve with * fix tests * stand up a server on 127.0.0.1 for test * tighten up / cleanup code, consolidate + refactor - lazily fs.outputfile’s - move sslIpServers to be global - add remove all CA utility * Improve proxy_spec test * Don't crash on server error events * feedback * derp Co-authored-by: Brian Mann <brian.mann86@gmail.com>
This commit is contained in:
committed by
Brian Mann
parent
1a4ac7d84c
commit
7b85344b84
@@ -112,15 +112,27 @@ ServerExtensions = [{
|
||||
}]
|
||||
|
||||
class CA
|
||||
constructor: (caFolder) ->
|
||||
if not caFolder
|
||||
caFolder = path.join(os.tmpdir(), 'cy-ca')
|
||||
|
||||
randomSerialNumber: ->
|
||||
## generate random 16 bytes hex string
|
||||
sn = ""
|
||||
@baseCAFolder = caFolder
|
||||
@certsFolder = path.join(@baseCAFolder, "certs")
|
||||
@keysFolder = path.join(@baseCAFolder, "keys")
|
||||
|
||||
for i in [1..4]
|
||||
sn += ("00000000" + Math.floor(Math.random()*Math.pow(256, 4)).toString(16)).slice(-8)
|
||||
removeAll: ->
|
||||
fs
|
||||
.removeAsync(@baseCAFolder)
|
||||
.catchReturn({ code: "ENOENT" })
|
||||
|
||||
sn
|
||||
randomSerialNumber: ->
|
||||
## generate random 16 bytes hex string
|
||||
sn = ""
|
||||
|
||||
for i in [1..4]
|
||||
sn += ("00000000" + Math.floor(Math.random()*Math.pow(256, 4)).toString(16)).slice(-8)
|
||||
|
||||
sn
|
||||
|
||||
generateCA: ->
|
||||
generateKeyPairAsync({bits: 512})
|
||||
@@ -128,6 +140,7 @@ class CA
|
||||
cert = pki.createCertificate()
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = @randomSerialNumber()
|
||||
|
||||
cert.validity.notBefore = new Date()
|
||||
cert.validity.notAfter = new Date()
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10)
|
||||
@@ -140,9 +153,9 @@ class CA
|
||||
@CAkeys = keys
|
||||
|
||||
Promise.all([
|
||||
fs.writeFileAsync(path.join(@certsFolder, "ca.pem"), pki.certificateToPem(cert))
|
||||
fs.writeFileAsync(path.join(@keysFolder, "ca.private.key"), pki.privateKeyToPem(keys.privateKey))
|
||||
fs.writeFileAsync(path.join(@keysFolder, "ca.public.key"), pki.publicKeyToPem(keys.publicKey))
|
||||
fs.outputFileAsync(path.join(@certsFolder, "ca.pem"), pki.certificateToPem(cert))
|
||||
fs.outputFileAsync(path.join(@keysFolder, "ca.private.key"), pki.privateKeyToPem(keys.privateKey))
|
||||
fs.outputFileAsync(path.join(@keysFolder, "ca.public.key"), pki.publicKeyToPem(keys.publicKey))
|
||||
])
|
||||
|
||||
loadCA: ->
|
||||
@@ -198,9 +211,9 @@ class CA
|
||||
dest = mainHost.replace(asterisksRe, "_")
|
||||
|
||||
Promise.all([
|
||||
fs.writeFileAsync(path.join(@certsFolder, dest + ".pem"), certPem)
|
||||
fs.writeFileAsync(path.join(@keysFolder, dest + ".key"), keyPrivatePem)
|
||||
fs.writeFileAsync(path.join(@keysFolder, dest + ".public.key"), keyPublicPem)
|
||||
fs.outputFileAsync(path.join(@certsFolder, dest + ".pem"), certPem)
|
||||
fs.outputFileAsync(path.join(@keysFolder, dest + ".key"), keyPrivatePem)
|
||||
fs.outputFileAsync(path.join(@keysFolder, dest + ".public.key"), keyPublicPem)
|
||||
])
|
||||
.return([certPem, keyPrivatePem])
|
||||
|
||||
@@ -216,25 +229,12 @@ class CA
|
||||
path.join(@certsFolder, "ca.pem")
|
||||
|
||||
@create = (caFolder) ->
|
||||
ca = new CA
|
||||
ca = new CA(caFolder)
|
||||
|
||||
if not caFolder
|
||||
caFolder = path.join(os.tmpdir(), 'cy-ca')
|
||||
|
||||
ca.baseCAFolder = caFolder
|
||||
ca.certsFolder = path.join(ca.baseCAFolder, "certs")
|
||||
ca.keysFolder = path.join(ca.baseCAFolder, "keys")
|
||||
|
||||
Promise.all([
|
||||
fs.ensureDirAsync(ca.baseCAFolder)
|
||||
fs.ensureDirAsync(ca.certsFolder)
|
||||
fs.ensureDirAsync(ca.keysFolder)
|
||||
])
|
||||
.then ->
|
||||
fs.statAsync(path.join(ca.certsFolder, "ca.pem"))
|
||||
.bind(ca)
|
||||
.then(ca.loadCA)
|
||||
.catch(ca.generateCA)
|
||||
fs.statAsync(path.join(ca.certsFolder, "ca.pem"))
|
||||
.bind(ca)
|
||||
.then(ca.loadCA)
|
||||
.catch(ca.generateCA)
|
||||
.return(ca)
|
||||
|
||||
module.exports = CA
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
_ = require("lodash")
|
||||
{ agent, connect } = require("@packages/network")
|
||||
allowDestroy = require("server-destroy-vvo")
|
||||
{ agent, allowDestroy, connect } = require("@packages/network")
|
||||
debug = require("debug")("cypress:https-proxy")
|
||||
fs = require("fs-extra")
|
||||
getProxyForUrl = require("proxy-from-env").getProxyForUrl
|
||||
@@ -14,6 +13,7 @@ url = require("url")
|
||||
fs = Promise.promisifyAll(fs)
|
||||
|
||||
sslServers = {}
|
||||
sslIpServers = {}
|
||||
sslSemaphores = {}
|
||||
|
||||
## https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record
|
||||
@@ -22,9 +22,14 @@ SSL_RECORD_TYPES = [
|
||||
128, 0 ## TODO: what do these unknown types mean?
|
||||
]
|
||||
|
||||
onError = (err) ->
|
||||
## these need to be caught to avoid crashing but do not affect anything
|
||||
debug('server error %o', { err })
|
||||
|
||||
class Server
|
||||
constructor: (@_ca, @_port) ->
|
||||
constructor: (@_ca, @_port, @_options) ->
|
||||
@_onError = null
|
||||
@_ipServers = sslIpServers
|
||||
|
||||
connect: (req, browserSocket, head, options = {}) ->
|
||||
## don't buffer writes - thanks a lot, Nagle
|
||||
@@ -217,50 +222,78 @@ class Server
|
||||
|
||||
_getPortFor: (hostname) ->
|
||||
@_getCertificatePathsFor(hostname)
|
||||
|
||||
.catch (err) =>
|
||||
@_generateMissingCertificates(hostname)
|
||||
|
||||
.then (data = {}) =>
|
||||
if net.isIP(hostname)
|
||||
return @_getServerPortForIp(hostname, data)
|
||||
|
||||
@_sniServer.addContext(hostname, data)
|
||||
|
||||
return @_sniPort
|
||||
|
||||
listen: (options = {}) ->
|
||||
new Promise (resolve) =>
|
||||
@_onError = options.onError
|
||||
_listenHttpsServer: (data) ->
|
||||
new Promise (resolve, reject) =>
|
||||
server = https.createServer(data)
|
||||
|
||||
@_sniServer = https.createServer({})
|
||||
allowDestroy(server)
|
||||
|
||||
allowDestroy(@_sniServer)
|
||||
server.once "error", reject
|
||||
server.on "upgrade", @_onUpgrade.bind(@, @_options.onUpgrade)
|
||||
server.on "request", @_onRequest.bind(@, @_options.onRequest)
|
||||
|
||||
@_sniServer.on "upgrade", @_onUpgrade.bind(@, options.onUpgrade)
|
||||
@_sniServer.on "request", @_onRequest.bind(@, options.onRequest)
|
||||
@_sniServer.listen 0, '127.0.0.1', =>
|
||||
## store the port of our current sniServer
|
||||
@_sniPort = @_sniServer.address().port
|
||||
server.listen 0, '127.0.0.1', =>
|
||||
port = server.address().port
|
||||
|
||||
debug("Created SNI HTTPS Proxy on port %s", @_sniPort)
|
||||
server.removeListener("error", reject)
|
||||
server.on "error", onError
|
||||
|
||||
resolve()
|
||||
resolve({ server, port })
|
||||
|
||||
## browsers will not do SNI for an IP address
|
||||
## so we need to serve 1 HTTPS server per IP
|
||||
## https://github.com/cypress-io/cypress/issues/771
|
||||
_getServerPortForIp: (ip, data) =>
|
||||
if server = sslIpServers[ip]
|
||||
return server.address().port
|
||||
|
||||
@_listenHttpsServer(data)
|
||||
.then ({ server, port }) ->
|
||||
sslIpServers[ip] = server
|
||||
|
||||
debug("Created IP HTTPS Proxy Server", { port, ip })
|
||||
|
||||
return port
|
||||
|
||||
listen: ->
|
||||
@_onError = @_options.onError
|
||||
|
||||
@_listenHttpsServer({})
|
||||
.tap ({ server, port}) =>
|
||||
@_sniPort = port
|
||||
@_sniServer = server
|
||||
|
||||
debug("Created SNI HTTPS Proxy Server", { port })
|
||||
|
||||
close: ->
|
||||
close = =>
|
||||
new Promise (resolve) =>
|
||||
@_sniServer.destroy(resolve)
|
||||
servers = _.values(sslIpServers).concat(@_sniServer)
|
||||
Promise.map servers, (server) =>
|
||||
Promise.fromCallback(server.destroy)
|
||||
.catch onError
|
||||
|
||||
close()
|
||||
.finally ->
|
||||
sslServers = {}
|
||||
.finally(module.exports.reset)
|
||||
|
||||
module.exports = {
|
||||
reset: ->
|
||||
sslServers = {}
|
||||
sslIpServers = {}
|
||||
|
||||
create: (ca, port, options = {}) ->
|
||||
srv = new Server(ca, port)
|
||||
srv = new Server(ca, port, options)
|
||||
|
||||
srv
|
||||
.listen(options)
|
||||
.listen()
|
||||
.return(srv)
|
||||
}
|
||||
|
||||
@@ -21,10 +21,9 @@
|
||||
"debug": "4.1.1",
|
||||
"fs-extra": "8.1.0",
|
||||
"lodash": "4.17.15",
|
||||
"node-forge": "0.6.49",
|
||||
"node-forge": "0.9.0",
|
||||
"proxy-from-env": "1.0.0",
|
||||
"semaphore": "1.1.0",
|
||||
"server-destroy-vvo": "1.0.1"
|
||||
"semaphore": "1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cypress/debugging-proxy": "2.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
https = require("https")
|
||||
Promise = require("bluebird")
|
||||
allowDestroy = require("server-destroy-vvo")
|
||||
{ allowDestroy } = require("@packages/network")
|
||||
certs = require("./certs")
|
||||
|
||||
defaultOnRequest = (req, res) ->
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{ allowDestroy } = require("@packages/network")
|
||||
http = require("http")
|
||||
path = require("path")
|
||||
httpsProxy = require("../../lib/proxy")
|
||||
@@ -28,6 +29,8 @@ module.exports = {
|
||||
start: (port) ->
|
||||
prx = http.createServer()
|
||||
|
||||
allowDestroy(prx)
|
||||
|
||||
dir = path.join(process.cwd(), "ca")
|
||||
|
||||
httpsProxy.create(dir, port, {
|
||||
@@ -61,7 +64,7 @@ module.exports = {
|
||||
|
||||
stop: ->
|
||||
new Promise (resolve) ->
|
||||
prx.close(resolve)
|
||||
prx.destroy(resolve)
|
||||
.then ->
|
||||
prx.proxy.close()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"
|
||||
|
||||
_ = require("lodash")
|
||||
DebugProxy = require("@cypress/debugging-proxy")
|
||||
fs = require("fs-extra")
|
||||
https = require("https")
|
||||
net = require("net")
|
||||
network = require("@packages/network")
|
||||
path = require("path")
|
||||
@@ -130,6 +132,7 @@ describe "Proxy", ->
|
||||
url: "http://localhost:8080/"
|
||||
proxy: "http://localhost:3333"
|
||||
})
|
||||
|
||||
.then (html) ->
|
||||
expect(html).to.include("http server")
|
||||
|
||||
@@ -152,6 +155,33 @@ describe "Proxy", ->
|
||||
proxy: "http://localhost:3333"
|
||||
})
|
||||
|
||||
## https://github.com/cypress-io/cypress/issues/771
|
||||
it "generates certs and can proxy requests for HTTPS requests to IPs", ->
|
||||
@sandbox.spy(@proxy, "_generateMissingCertificates")
|
||||
@sandbox.spy(@proxy, "_getServerPortForIp")
|
||||
|
||||
Promise.all([
|
||||
httpsServer.start(8445),
|
||||
@proxy._ca.removeAll()
|
||||
])
|
||||
.then =>
|
||||
request({
|
||||
strictSSL: false
|
||||
url: "https://127.0.0.1:8445/"
|
||||
proxy: "http://localhost:3333"
|
||||
})
|
||||
.then =>
|
||||
## this should not stand up its own https server
|
||||
request({
|
||||
strictSSL: false
|
||||
url: "https://localhost:8443/"
|
||||
proxy: "http://localhost:3333"
|
||||
})
|
||||
.then =>
|
||||
expect(@proxy._ipServers["127.0.0.1"]).to.be.an.instanceOf(https.Server)
|
||||
expect(@proxy._getServerPortForIp).to.be.calledWith('127.0.0.1').and.be.calledOnce
|
||||
expect(@proxy._generateMissingCertificates).to.be.calledTwice
|
||||
|
||||
context "closing", ->
|
||||
it "resets sslServers and can reopen", ->
|
||||
request({
|
||||
@@ -256,7 +286,7 @@ describe "Proxy", ->
|
||||
})
|
||||
.then =>
|
||||
throw new Error('should not succeed')
|
||||
.catch { message: 'Error: socket hang up' }, =>
|
||||
.catch { message: "Error: socket hang up" }, =>
|
||||
expect(createProxyConn).to.not.be.called
|
||||
expect(createSocket).to.be.calledWith({
|
||||
port: @proxy._sniPort
|
||||
|
||||
Reference in New Issue
Block a user