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

Fix proxying HTTPS requests to IP addresses #4947

Merged
merged 12 commits into from
Sep 12, 2019
60 changes: 30 additions & 30 deletions packages/https-proxy/lib/ca.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -112,22 +112,35 @@ ServerExtensions = [{
}]

class CA
constructor: (caFolder) ->
if not caFolder
caFolder = path.join(os.tmpdir(), 'cy-ca')

@baseCAFolder = caFolder
@certsFolder = path.join(@baseCAFolder, "certs")
@keysFolder = path.join(@baseCAFolder, "keys")

randomSerialNumber: ->
## generate random 16 bytes hex string
sn = ""
removeAll: ->
fs
.removeAsync(@baseCAFolder)
.catchReturn({ code: "ENOENT" })

for i in [1..4]
sn += ("00000000" + Math.floor(Math.random()*Math.pow(256, 4)).toString(16)).slice(-8)
randomSerialNumber: ->
## generate random 16 bytes hex string
sn = ""

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})
.then (keys) =>
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)
Expand All @@ -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: ->
Expand Down Expand Up @@ -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])

Expand All @@ -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
79 changes: 56 additions & 23 deletions packages/https-proxy/lib/server.coffee
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,6 +13,7 @@ url = require("url")
fs = Promise.promisifyAll(fs)

sslServers = {}
sslIpServers = {}
sslSemaphores = {}

## https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record
Expand All @@ -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
Expand Down Expand Up @@ -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)

allowDestroy(server)

server.once "error", reject
server.on "upgrade", @_onUpgrade.bind(@, @_options.onUpgrade)
server.on "request", @_onRequest.bind(@, @_options.onRequest)

server.listen 0, '127.0.0.1', =>
port = server.address().port

server.removeListener("error", reject)
server.on "error", onError

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

@_sniServer = https.createServer({})
debug("Created IP HTTPS Proxy Server", { port, ip })

allowDestroy(@_sniServer)
return port

@_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
listen: ->
@_onError = @_options.onError

debug("Created SNI HTTPS Proxy on port %s", @_sniPort)
@_listenHttpsServer({})
.tap ({ server, port}) =>
@_sniPort = port
@_sniServer = server

resolve()
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)
}
5 changes: 2 additions & 3 deletions packages/https-proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/https-proxy/test/helpers/https_server.coffee
Original file line number Diff line number Diff line change
@@ -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) ->
Expand Down
5 changes: 4 additions & 1 deletion packages/https-proxy/test/helpers/proxy.coffee
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{ allowDestroy } = require("@packages/network")
http = require("http")
path = require("path")
httpsProxy = require("../../lib/proxy")
Expand Down Expand Up @@ -28,6 +29,8 @@ module.exports = {
start: (port) ->
prx = http.createServer()

allowDestroy(prx)

dir = path.join(process.cwd(), "ca")

httpsProxy.create(dir, port, {
Expand Down Expand Up @@ -61,7 +64,7 @@ module.exports = {

stop: ->
new Promise (resolve) ->
prx.close(resolve)
prx.destroy(resolve)
.then ->
prx.proxy.close()
}
32 changes: 31 additions & 1 deletion packages/https-proxy/test/integration/proxy_spec.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -130,6 +132,7 @@ describe "Proxy", ->
url: "http://localhost:8080/"
proxy: "http://localhost:3333"
})

.then (html) ->
expect(html).to.include("http server")

Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions packages/network/lib/allow-destroy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import net from 'net'

/**
* `allowDestroy` adds a `destroy` method to a `net.Server`. `destroy(cb)`
* will kill all open connections and call `cb` when the server is closed.
*
* Note: `server-destroy` NPM package cannot be used - it does not track
* `secureConnection` events.
*/
export function allowDestroy (server: net.Server) {
let connections: net.Socket[] = []

function trackConn (conn) {
connections.push(conn)

conn.on('close', () => {
connections = connections.filter((connection) => connection !== conn)
})
}

server.on('connection', trackConn)
server.on('secureConnection', trackConn)

// @ts-ignore Property 'destroy' does not exist on type 'Server'.
server.destroy = function (cb) {
server.close(cb)
connections.map((connection) => connection.destroy())
}

return server
}
9 changes: 6 additions & 3 deletions packages/network/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import agent from './agent'
import * as connect from './connect'
import { allowDestroy } from './allow-destroy'

export { agent }

export { connect }
export {
agent,
allowDestroy,
connect,
}
Loading