Skip to content

Commit

Permalink
Fix proxying HTTPS requests to IP addresses (#4947)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
flotwig and brian-mann committed Sep 12, 2019
1 parent 1a4ac7d commit 7b85344
Show file tree
Hide file tree
Showing 13 changed files with 186 additions and 124 deletions.
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

4 comments on commit 7b85344

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 7b85344 Sep 12, 2019

Choose a reason for hiding this comment

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

Circle has built the linux x64 version of the Test Runner.

You can install this pre-release platform-specific build using instructions at https://on.cypress.io/installing-cypress#Install-pre-release-version.

You will need to use custom CYPRESS_INSTALL_BINARY url and install Cypress using an url instead of the version.

export CYPRESS_INSTALL_BINARY=https://cdn.cypress.io/beta/binary/3.5.0/linux-x64/circle-develop-7b85344b84bf29625d26400fde44cdd91e11269c-151455/cypress.zip
npm install https://cdn.cypress.io/beta/npm/3.5.0/circle-develop-7b85344b84bf29625d26400fde44cdd91e11269c-151465/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 7b85344 Sep 12, 2019

Choose a reason for hiding this comment

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

AppVeyor has built the win32 x64 version of the Test Runner.

You can install this pre-release platform-specific build using instructions at https://on.cypress.io/installing-cypress#Install-pre-release-version.

You will need to use custom CYPRESS_INSTALL_BINARY url and install Cypress using an url instead of the version.

set CYPRESS_INSTALL_BINARY=https://cdn.cypress.io/beta/binary/3.5.0/win32-x64/appveyor-develop-7b85344b84bf29625d26400fde44cdd91e11269c-27375710/cypress.zip
npm install https://cdn.cypress.io/beta/binary/3.5.0/win32-x64/appveyor-develop-7b85344b84bf29625d26400fde44cdd91e11269c-27375710/cypress.zip

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 7b85344 Sep 12, 2019

Choose a reason for hiding this comment

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

AppVeyor has built the win32 ia32 version of the Test Runner.

You can install this pre-release platform-specific build using instructions at https://on.cypress.io/installing-cypress#Install-pre-release-version.

You will need to use custom CYPRESS_INSTALL_BINARY url and install Cypress using an url instead of the version.

set CYPRESS_INSTALL_BINARY=https://cdn.cypress.io/beta/binary/3.5.0/win32-ia32/appveyor-develop-7b85344b84bf29625d26400fde44cdd91e11269c-27375710/cypress.zip
npm install https://cdn.cypress.io/beta/binary/3.5.0/win32-ia32/appveyor-develop-7b85344b84bf29625d26400fde44cdd91e11269c-27375710/cypress.zip

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 7b85344 Sep 12, 2019

Choose a reason for hiding this comment

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

Circle has built the darwin x64 version of the Test Runner.

You can install this pre-release platform-specific build using instructions at https://on.cypress.io/installing-cypress#Install-pre-release-version.

You will need to use custom CYPRESS_INSTALL_BINARY url and install Cypress using an url instead of the version.

export CYPRESS_INSTALL_BINARY=https://cdn.cypress.io/beta/binary/3.5.0/darwin-x64/circle-develop-7b85344b84bf29625d26400fde44cdd91e11269c-151477/cypress.zip
npm install https://cdn.cypress.io/beta/npm/3.5.0/circle-develop-7b85344b84bf29625d26400fde44cdd91e11269c-151476/cypress.tgz

Please sign in to comment.