diff --git a/.circleci/cache-version.txt b/.circleci/cache-version.txt index 5681ae5feaf..cf8fdba343d 100644 --- a/.circleci/cache-version.txt +++ b/.circleci/cache-version.txt @@ -1,2 +1,2 @@ # Bump this version to force CI to re-create the cache from scratch. -10-10-2025 +10-14-2025 diff --git a/.circleci/src/pipeline/@pipeline.yml b/.circleci/src/pipeline/@pipeline.yml index 3fa6302399a..b2f2b217ebe 100644 --- a/.circleci/src/pipeline/@pipeline.yml +++ b/.circleci/src/pipeline/@pipeline.yml @@ -1785,7 +1785,7 @@ jobs: source ./scripts/ensure-node.sh yarn lerna run types - sanitize-verify-and-store-mocha-results: - expectedResultCount: 8 + expectedResultCount: 7 verify-release-readiness: <<: *defaults diff --git a/guides/esm-migration.md b/guides/esm-migration.md index 7d7053466ef..7dd44139196 100644 --- a/guides/esm-migration.md +++ b/guides/esm-migration.md @@ -47,7 +47,7 @@ When migrating some of these projects away from the `ts-node` entry [see `@packa - [ ] packages/extension - [ ] packages/frontend-shared **PARTIAL** - entry point is JS - [x] packages/electron ✅ **COMPLETED** -- [ ] packages/https-proxy - higher priority +- [x] packages/https-proxy - ✅ **COMPLETED** - [x] packages/icons ✅ **COMPLETED** - [x] packages/launcher ✅ **COMPLETED** - [x] packages/launchpad ✅ **COMPLETED** @@ -96,7 +96,7 @@ When migrating some of these projects away from the `ts-node` entry [see `@packa - [x] packages/electron ✅ **COMPLETED** - [x] packages/error ✅ **COMPLETED** - [ ] packages/extension -- [ ] packages/https-proxy +- [x] packages/https-proxy ✅ **COMPLETED** - [x] packages/electron ✅ **COMPLETED** - [x] packages/icons ✅ **COMPLETED** - [x] packages/launcher ✅ **COMPLETED** diff --git a/packages/driver/cypress/plugins/server.js b/packages/driver/cypress/plugins/server.js index 6084d20466a..209c03640c7 100644 --- a/packages/driver/cypress/plugins/server.js +++ b/packages/driver/cypress/plugins/server.js @@ -3,7 +3,7 @@ const auth = require('basic-auth') const bodyParser = require('body-parser') const express = require('express') const http = require('http') -const httpsProxy = require('@packages/https-proxy') +const { create: createHttpsServer } = require('@packages/https-proxy/test/helpers/https_server') const path = require('path') const Promise = require('bluebird') const multer = require('multer') @@ -377,7 +377,7 @@ const createApp = (port) => { }) app.get('/aut-commands', async (req, res) => { - const script = (await fs.readFileAsync(path.join(__dirname, '..', 'fixtures', 'aut-commands.js'))).toString() + const script = (await fs.readFile(path.join(__dirname, '..', 'fixtures', 'aut-commands.js'))).toString() res.send(` @@ -411,7 +411,7 @@ httpPorts.forEach((port) => { // style to make sure we implement cookie handling correctly httpsPorts.forEach((port) => { const httpsApp = createApp(port) - const httpsServer = httpsProxy.httpsServer(httpsApp) + const httpsServer = createHttpsServer(httpsApp) return httpsServer.listen(port, () => { // eslint-disable-next-line no-console diff --git a/packages/https-proxy/.eslintignore b/packages/https-proxy/.eslintignore new file mode 100644 index 00000000000..362dcf966b4 --- /dev/null +++ b/packages/https-proxy/.eslintignore @@ -0,0 +1,3 @@ +cjs/ +esm/ +tsconfig.json \ No newline at end of file diff --git a/packages/https-proxy/.gitignore b/packages/https-proxy/.gitignore index 88617f22386..4e93a24596c 100644 --- a/packages/https-proxy/.gitignore +++ b/packages/https-proxy/.gitignore @@ -1 +1,3 @@ ca/ +cjs/ +esm/ diff --git a/packages/https-proxy/README.md b/packages/https-proxy/README.md index b530ffa69b4..b300e7faff4 100644 --- a/packages/https-proxy/README.md +++ b/packages/https-proxy/README.md @@ -6,6 +6,8 @@ This package enables Cypress to inspect and modify bytes coming in and out of th ## Testing +Since `vitest` runs specs in parallel by default, we get collisions in the `proxy.spec.ts` and `server.spec.ts` as there is a proxy server running in the background for `proxy.spec.ts` while we are testing the implements of `server.spec.ts`. These tests cannot run at the same time, hence why we leverage the `no-file-parallelism` option. + ```bash yarn workspace @packages/https-proxy test yarn workspace @packages/https-proxy test-watch diff --git a/packages/https-proxy/https.js b/packages/https-proxy/https.js deleted file mode 100644 index c3fe9b3ed89..00000000000 --- a/packages/https-proxy/https.js +++ /dev/null @@ -1,13 +0,0 @@ -const Promise = require('bluebird') -const proxy = require('./test/helpers/proxy') -const httpServer = require('./test/helpers/http_server') -const httpsServer = require('./test/helpers/https_server') - -Promise.join( - httpServer.start(8888), - - httpsServer.start(8444), - httpsServer.start(8445), - - proxy.start(3333), -) diff --git a/packages/https-proxy/index.js b/packages/https-proxy/index.js deleted file mode 100644 index 21654d38bb8..00000000000 --- a/packages/https-proxy/index.js +++ /dev/null @@ -1,5 +0,0 @@ -require('@packages/ts/register') - -module.exports = require('./lib/proxy') - -module.exports.CA = require('./lib/ca') diff --git a/packages/https-proxy/lib/ca.js b/packages/https-proxy/lib/ca.ts similarity index 51% rename from packages/https-proxy/lib/ca.js rename to packages/https-proxy/lib/ca.ts index d26612daa10..d16fcd59142 100644 --- a/packages/https-proxy/lib/ca.js +++ b/packages/https-proxy/lib/ca.ts @@ -1,23 +1,19 @@ -const _ = require('lodash') -const debug = require('debug')('cypress:https-proxy:ca') -const os = require('os') -const path = require('path') -const Forge = require('node-forge') -const Promise = require('bluebird') -let fs = require('fs-extra') +import _ from 'lodash' +import { promisify } from 'util' +import debugModule from 'debug' +import os from 'os' +import path from 'path' +import { pki, md } from 'node-forge' +import fs from 'fs-extra' + +const debug = debugModule('cypress:https-proxy:ca') // if this is higher than the user's cached CA version, the Cypress // certificate cache will be cleared so that new certificates can // supersede older ones const CA_VERSION = 1 -fs = Promise.promisifyAll(fs) - -const { - pki, -} = Forge - -const generateKeyPairAsync = Promise.promisify(pki.rsa.generateKeyPair) +const generateKeyPairAsync = promisify(pki.rsa.generateKeyPair) const ipAddressRe = /^[\d\.]+$/ @@ -118,12 +114,18 @@ const ServerExtensions = [{ name: 'subjectKeyIdentifier', }] -function hostnameToFilename (hostname) { +function hostnameToFilename (hostname: string) { return hostname.replace(/\*/g, '_') } -class CA { - constructor (caFolder) { +export class CA { + baseCAFolder: string + certsFolder: string + keysFolder: string + CAcert: pki.Certificate + CAkeys: pki.rsa.KeyPair + + constructor (caFolder?: string) { if (!caFolder) { caFolder = path.join(os.tmpdir(), 'cy-ca') } @@ -133,10 +135,14 @@ class CA { this.keysFolder = path.join(this.baseCAFolder, 'keys') } - removeAll () { - return fs - .removeAsync(this.baseCAFolder) - .catchReturn({ code: 'ENOENT' }) + async removeAll () { + try { + await fs.remove(this.baseCAFolder) + } catch (err) { + if (err.code !== 'ENOENT') { + throw err + } + } } randomSerialNumber () { @@ -150,53 +156,58 @@ class CA { return sn } - generateCA () { - return generateKeyPairAsync({ bits: 2048 }) - .then((keys) => { - const cert = pki.createCertificate() - - cert.publicKey = keys.publicKey - cert.serialNumber = this.randomSerialNumber() - - cert.validity.notBefore = new Date() - cert.validity.notAfter = new Date() - cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10) - cert.setSubject(CAattrs) - cert.setIssuer(CAattrs) - cert.setExtensions(CAextensions) - cert.sign(keys.privateKey, Forge.md.sha256.create()) - - this.CAcert = cert - this.CAkeys = keys - - return Promise.all([ - fs.outputFileAsync(this.getCACertPath(), pki.certificateToPem(cert)), - fs.outputFileAsync(this.getCAPrivateKeyPath(), pki.privateKeyToPem(keys.privateKey)), - fs.outputFileAsync(this.getCAPublicKeyPath(), pki.publicKeyToPem(keys.publicKey)), - this.writeCAVersion(), - ]) - }) + async generateCA (): Promise { + const keys = await generateKeyPairAsync({ bits: 2048 }) + + const cert = pki.createCertificate() + + cert.publicKey = keys.publicKey + cert.serialNumber = this.randomSerialNumber() + + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10) + cert.setSubject(CAattrs) + cert.setIssuer(CAattrs) + cert.setExtensions(CAextensions) + cert.sign(keys.privateKey, md.sha256.create()) + + this.CAcert = cert + this.CAkeys = keys + + const certPem = pki.certificateToPem(cert) + const keyPrivatePem = pki.privateKeyToPem(keys.privateKey) + const keyPublicPem = pki.publicKeyToPem(keys.publicKey) + + await Promise.all([ + fs.outputFile(this.getCACertPath(), certPem), + fs.outputFile(this.getCAPrivateKeyPath(), keyPrivatePem), + fs.outputFile(this.getCAPublicKeyPath(), keyPublicPem), + this.writeCAVersion(), + ]) + + return undefined } - loadCA () { - return Promise.props({ - certPEM: fs.readFileAsync(this.getCACertPath(), 'utf-8'), - keyPrivatePEM: fs.readFileAsync(this.getCAPrivateKeyPath(), 'utf-8'), - keyPublicPEM: fs.readFileAsync(this.getCAPublicKeyPath(), 'utf-8'), - }) - .then((results) => { - this.CAcert = pki.certificateFromPem(results.certPEM) + async loadCA (): Promise { + const [certPEM, keyPrivatePEM, keyPublicPEM] = await Promise.all([ + fs.readFile(this.getCACertPath(), 'utf-8'), + fs.readFile(this.getCAPrivateKeyPath(), 'utf-8'), + fs.readFile(this.getCAPublicKeyPath(), 'utf-8'), + ]) - this.CAkeys = { - privateKey: pki.privateKeyFromPem(results.keyPrivatePEM), - publicKey: pki.publicKeyFromPem(results.keyPublicPEM), - } - }) - .return(undefined) + this.CAcert = pki.certificateFromPem(certPEM) + + this.CAkeys = { + privateKey: pki.privateKeyFromPem(keyPrivatePEM), + publicKey: pki.publicKeyFromPem(keyPublicPEM), + } + + return undefined } - generateServerCertificateKeys (hosts) { - hosts = [].concat(hosts) + async generateServerCertificateKeys (hostsArg: string): Promise<[string, string]> { + const hosts: string[] = [].concat(hostsArg) const mainHost = hosts[0] const keysServer = pki.rsa.generateKeyPair(2048) @@ -219,7 +230,8 @@ class CA { certServer.setIssuer(this.CAcert.issuer.attributes) certServer.setExtensions(ServerExtensions.concat([{ name: 'subjectAltName', - altNames: hosts.map((host) => { + // @ts-expect-error + altNames: hosts.map((host: string) => { if (host.match(ipAddressRe)) { return { type: 7, ip: host } } @@ -228,7 +240,7 @@ class CA { }), }])) - certServer.sign(this.CAkeys.privateKey, Forge.md.sha256.create()) + certServer.sign(this.CAkeys.privateKey, md.sha256.create()) const certPem = pki.certificateToPem(certServer) const keyPrivatePem = pki.privateKeyToPem(keysServer.privateKey) @@ -236,42 +248,47 @@ class CA { const baseFilename = hostnameToFilename(mainHost) - return Promise.all([ - fs.outputFileAsync(this.getCertPath(baseFilename), certPem), - fs.outputFileAsync(this.getPrivateKeyPath(baseFilename), keyPrivatePem), - fs.outputFileAsync(this.getPublicKeyPath(baseFilename), keyPublicPem), + await Promise.all([ + fs.outputFile(this.getCertPath(baseFilename), certPem), + fs.outputFile(this.getPrivateKeyPath(baseFilename), keyPrivatePem), + fs.outputFile(this.getPublicKeyPath(baseFilename), keyPublicPem), ]) - .return([certPem, keyPrivatePem]) + + return [certPem, keyPrivatePem] } - clearDataForHostname (hostname) { + async clearDataForHostname (hostname: string): Promise { const baseFilename = hostnameToFilename(hostname) - return Promise.all([ + await Promise.all([ fs.remove(this.getCertPath(baseFilename)), fs.remove(this.getPrivateKeyPath(baseFilename)), fs.remove(this.getPublicKeyPath(baseFilename)), ]) + + return undefined } - getCertificateKeysForHostname (hostname) { + async getCertificateKeysForHostname (hostname: string): Promise<[string, string]> { const baseFilename = hostnameToFilename(hostname) - return Promise.all([ - fs.readFileAsync(this.getCertPath(baseFilename)), - fs.readFileAsync(this.getPrivateKeyPath(baseFilename)), + const [certPem, keyPrivatePem] = await Promise.all([ + fs.readFile(this.getCertPath(baseFilename)), + fs.readFile(this.getPrivateKeyPath(baseFilename)), ]) + + return [certPem.toString(), keyPrivatePem.toString()] } - getPrivateKeyPath (baseFilename) { + getPrivateKeyPath (baseFilename: string) { return path.join(this.keysFolder, `${baseFilename}.key`) } - getPublicKeyPath (baseFilename) { + getPublicKeyPath (baseFilename: string) { return path.join(this.keysFolder, `${baseFilename}.public.key`) } - getCertPath (baseFilename) { + getCertPath (baseFilename: string) { return path.join(this.certsFolder, `${baseFilename}.pem`) } @@ -291,42 +308,53 @@ class CA { return path.join(this.baseCAFolder, 'ca_version.txt') } - getCAVersion () { - return fs.readFileAsync(this.getCAVersionPath()) - .then(Number) - .catch(function (err) { + async getCAVersion () { + try { + const version = await fs.readFile(this.getCAVersionPath()) + + return Number(version) + } catch (err) { debug('error reading cached CA version: %o', { err }) return 0 - }) + } } writeCAVersion () { - return fs.outputFileAsync(this.getCAVersionPath(), String(CA_VERSION)) + return fs.outputFile(this.getCAVersionPath(), String(CA_VERSION)) } - assertMinimumCAVersion () { - return this.getCAVersion().then(function (actualVersion) { - debug('checking CA version %o', { actualVersion, CA_VERSION }) - if (actualVersion >= CA_VERSION) { - return - } + async assertMinimumCAVersion () { + const actualVersion = await this.getCAVersion() - throw new Error(`expected ca_version to be >= ${CA_VERSION}, but it was ${actualVersion}`) - }) + debug('checking CA version %o', { actualVersion, CA_VERSION }) + if (actualVersion >= CA_VERSION) { + return + } + + throw new Error(`expected ca_version to be >= ${CA_VERSION}, but it was ${actualVersion}`) } - static create (caFolder) { + static async create (caFolder?: string) { const ca = new CA(caFolder) - return fs.statAsync(ca.getCACertPath()) - .bind(ca) - .then(ca.assertMinimumCAVersion) - .tapCatch(ca.removeAll) - .then(ca.loadCA) - .catch(ca.generateCA) - .return(ca) + try { + await fs.stat(ca.getCACertPath()) + try { + await ca.assertMinimumCAVersion() + } catch (err) { + debug('CA version mismatch or is missing, removing all certs and keys in the CA folder') + await ca.removeAll() + throw new Error('CA version mismatch or is missing, catch below and regenerate certs and keys') + } + + await ca.loadCA() + + return ca + } catch (err) { + await ca.generateCA() + } + + return ca } } - -module.exports = CA diff --git a/packages/https-proxy/lib/index.ts b/packages/https-proxy/lib/index.ts new file mode 100644 index 00000000000..3f896a57b66 --- /dev/null +++ b/packages/https-proxy/lib/index.ts @@ -0,0 +1,4 @@ +import { create as createProxy, reset as resetProxy } from './proxy' +import { CA } from './ca' + +export { createProxy, resetProxy, CA } diff --git a/packages/https-proxy/lib/proxy.js b/packages/https-proxy/lib/proxy.js deleted file mode 100644 index dcdffbae816..00000000000 --- a/packages/https-proxy/lib/proxy.js +++ /dev/null @@ -1,20 +0,0 @@ -const CA = require('./ca') -const Server = require('./server') - -module.exports = { - create (dir, port, options) { - return CA.create(dir) - .then((ca) => { - return Server.create(ca, port, options) - }) - }, - - reset () { - return Server.reset() - }, - - httpsServer (onRequest) { - return require('../test/helpers/https_server').create(onRequest) - }, - -} diff --git a/packages/https-proxy/lib/proxy.ts b/packages/https-proxy/lib/proxy.ts new file mode 100644 index 00000000000..bb1c181e489 --- /dev/null +++ b/packages/https-proxy/lib/proxy.ts @@ -0,0 +1,14 @@ +import { CA } from './ca' +import { create as createServer, reset as resetServer } from './server' + +export async function create (dir: string, port: number, options: any) { + const ca = await CA.create(dir) + + const server = await createServer(ca, port, options) + + return server +} + +export function reset () { + return resetServer() +} diff --git a/packages/https-proxy/lib/server.js b/packages/https-proxy/lib/server.js deleted file mode 100644 index 7115c3075c8..00000000000 --- a/packages/https-proxy/lib/server.js +++ /dev/null @@ -1,346 +0,0 @@ -const _ = require('lodash') -const { allowDestroy, connect, httpUtils } = require('@packages/network') -const debug = require('debug')('cypress:https-proxy') -const https = require('https') -const net = require('net') -const parse = require('./util/parse') -const Promise = require('bluebird') -const semaphore = require('semaphore') -const url = require('url') - -let sslServers = {} -let sslIpServers = {} -const sslSemaphores = {} - -// https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record -const SSL_RECORD_TYPES = [ - 22, // Handshake - 128, 0, // TODO: what do these unknown types mean? -] - -let onError = (err) => { - // these need to be caught to avoid crashing but do not affect anything - return debug('server error %o', { err }) -} - -class Server { - constructor (_ca, _port, _options) { - this._getServerPortForIp = this._getServerPortForIp.bind(this) - this._ca = _ca - this._port = _port - this._options = _options - this._onError = null - this._ipServers = sslIpServers - } - - connect (req, browserSocket, head, options = {}) { - // the SNI server requires a hostname, so if the hostname is blank, - // destroy the socket and fail fast - const { hostname } = url.parse(`https://${req.url}`) - - if (!hostname) { - browserSocket.destroy() - - return debug(`Invalid hostname for request url ${req.url}`) - } - - // don't buffer writes - thanks a lot, Nagle - // https://github.com/cypress-io/cypress/issues/3192 - browserSocket.setNoDelay(true) - - debug('Writing browserSocket connection headers %o', { url: req.url, headLength: _.get(head, 'length'), headers: req.headers }) - - browserSocket.on('error', (err) => { - // TODO: shouldn't we destroy the upstream socket here? - // and also vise versa if the upstream socket throws? - // we may get this "for free" though because piping will - // automatically forward the TCP errors...? - - // nothing to do except catch here, the browser has d/c'd - return debug('received error on client browserSocket %o', { - err, url: req.url, - }) - }) - - browserSocket.write('HTTP/1.1 200 OK\r\n') - - if (req.headers['proxy-connection'] === 'keep-alive') { - browserSocket.write('Proxy-Connection: keep-alive\r\n') - browserSocket.write('Connection: keep-alive\r\n') - } - - browserSocket.write('\r\n') - - // if we somehow already have the head here - if (_.get(head, 'length')) { - // then immediately make up the connection - return this._onFirstHeadBytes(req, browserSocket, head, options) - } - - // else once we get it make the connection later - return browserSocket.once('data', (data) => { - return this._onFirstHeadBytes(req, browserSocket, data, options) - }) - } - - _onFirstHeadBytes (req, browserSocket, head) { - debug('Got first head bytes %o', { url: req.url, head: _.chain(head).invoke('toString').slice(0, 64).join('').value() }) - - browserSocket.pause() - - return this._onServerConnectData(req, browserSocket, head) - } - - _onUpgrade (fn, req, browserSocket, head) { - debug('upgrade', req.url) - if (fn) { - return fn.call(this, req, browserSocket, head) - } - } - - _onRequest (fn, req, res) { - const hostPort = parse.hostAndPort(req.url, req.headers, 443) - - req.url = url.format({ - protocol: 'https:', - hostname: hostPort.host, - port: hostPort.port, - }) + req.url - - if (fn) { - return fn.call(this, req, res) - } - } - - _makeConnection (browserSocket, head, port, hostname) { - const onSocket = (err, upstreamSocket) => { - debug('received upstreamSocket callback for request %o', { port, hostname, err }) - - onError = (err) => { - browserSocket.destroy(err) - - if (this._onError) { - return this._onError(err, browserSocket, head, port) - } - } - - if (err) { - return onError(err) - } - - upstreamSocket.setNoDelay(true) - upstreamSocket.on('error', onError) - - browserSocket.emit('upstream-connected', upstreamSocket) - - browserSocket.pipe(upstreamSocket) - upstreamSocket.pipe(browserSocket) - upstreamSocket.write(head) - - return browserSocket.resume() - } - - if (!port) { - port = '443' - } - - return connect.createRetryingSocket({ port, host: hostname }, onSocket) - } - - _onServerConnectData (req, browserSocket, head) { - let sem; let sslServer - const firstBytes = head[0] - - const makeConnection = (port) => { - debug('Making intercepted connection to %s', port) - - return this._makeConnection(browserSocket, head, port, 'localhost') - } - - if (!SSL_RECORD_TYPES.includes(firstBytes)) { - // if this isn't an SSL request then go - // ahead and make the connection now - return makeConnection(this._port) - } - - // else spin up the SNI server - const { hostname } = url.parse(`https://${req.url}`) - - sslServer = sslServers[hostname] - - if (sslServer) { - return makeConnection(sslServer.port) - } - - // only be creating one SSL server per hostname at once - if (!(sem = sslSemaphores[hostname])) { - sem = (sslSemaphores[hostname] = semaphore(1)) - } - - return sem.take(() => { - const leave = () => { - return process.nextTick(() => { - return sem.leave() - }) - } - - sslServer = sslServers[hostname] - - if (sslServer) { - leave() - - return makeConnection(sslServer.port) - } - - return this._getPortFor(hostname) - .catch(async (err) => { - debug('Error adding context, deleting certs and regenning %o', { hostname, err }) - - // files on disk can be corrupted, so try again - // @see https://github.com/cypress-io/cypress/issues/8705 - await this._ca.clearDataForHostname(hostname) - - return this._getPortFor(hostname) - }) - .then((port) => { - sslServers[hostname] = { port } - - leave() - - return makeConnection(port) - }) - .catch((err) => { - debug('Error making connection %o', { err }) - - browserSocket.destroy(err) - - leave() - - if (this._onError) { - return this._onError(err, browserSocket, head) - } - }) - }) - } - - _normalizeKeyAndCert (certPem, privateKeyPem) { - return { - key: privateKeyPem, - cert: certPem, - } - } - - _getCertificatePathsFor (hostname) { - return this._ca.getCertificateKeysForHostname(hostname) - .spread(this._normalizeKeyAndCert) - } - - _generateMissingCertificates (hostname) { - return this._ca.generateServerCertificateKeys(hostname) - .spread(this._normalizeKeyAndCert) - } - - _getPortFor (hostname) { - return this._getCertificatePathsFor(hostname) - .catch((err) => { - return this._generateMissingCertificates(hostname) - }).then((data = {}) => { - if (net.isIP(hostname)) { - return this._getServerPortForIp(hostname, data) - } - - this._sniServer.addContext(hostname, data) - - return this._sniPort - }) - } - - _listenHttpsServer (data) { - return new Promise((resolve, reject) => { - const server = https.createServer({ - ...data, - ...httpUtils.lenientOptions, - }) - - allowDestroy(server) - - server.once('error', reject) - server.on('upgrade', this._onUpgrade.bind(this, this._options.onUpgrade)) - server.on('request', this._onRequest.bind(this, this._options.onRequest)) - - return server.listen(0, '127.0.0.1', () => { - const { - port, - } = server.address() - - server.removeListener('error', reject) - server.on('error', onError) - - return 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) { - let server - - server = sslIpServers[ip] - - if (server) { - return server.address().port - } - - return this._listenHttpsServer(data) - .then(({ server, port }) => { - sslIpServers[ip] = server - - debug('Created IP HTTPS Proxy Server', { port, ip }) - - return port - }) - } - - listen () { - this._onError = this._options.onError - - return this._listenHttpsServer({}) - .tap(({ server, port }) => { - this._sniPort = port - this._sniServer = server - - return debug('Created SNI HTTPS Proxy Server', { port }) - }) - } - - close () { - const close = () => { - const servers = _.values(sslIpServers).concat(this._sniServer) - - return Promise.map(servers, (server) => { - return Promise.fromCallback(server.destroy) - .catch(onError) - }) - } - - return close() - .finally(module.exports.reset) - } -} - -module.exports = { - reset () { - sslServers = {} - sslIpServers = {} - }, - - create (ca, port, options = {}) { - const srv = new Server(ca, port, options) - - return srv - .listen() - .return(srv) - }, -} diff --git a/packages/https-proxy/lib/server.ts b/packages/https-proxy/lib/server.ts new file mode 100644 index 00000000000..292f63381a9 --- /dev/null +++ b/packages/https-proxy/lib/server.ts @@ -0,0 +1,383 @@ +import _ from 'lodash' +import { allowDestroy, connect, httpUtils } from '@packages/network' +import debugModule from 'debug' +import https from 'https' +import net from 'net' +import { hostAndPort } from './util/parse' +import semaphore from 'semaphore' +import url from 'url' +import type { CA } from './ca' +import type { IncomingMessage, ServerResponse } from 'http' +import type { AddressInfo } from 'net' + +type ServerOptions = { + onUpgrade?: (req: IncomingMessage, browserSocket: net.Socket, head: Buffer) => void | undefined + onRequest?: (req: IncomingMessage, res: ServerResponse) => void | undefined + onError?: (err: Error, socket: net.Socket, head: Buffer, port: string) => void +} + +const debug = debugModule('cypress:https-proxy') + +let sslServers: Record = {} +let sslIpServers: Record> = {} +const sslSemaphores: Record = {} + +// https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record +const SSL_RECORD_TYPES = [ + 22, // Handshake + 128, 0, // TODO: what do these unknown types mean? +] + +let onError = (err: Error) => { + // these need to be caught to avoid crashing but do not affect anything + return debug('server error %o', { err }) +} + +export class Server { + _ca: CA + _port: number + _options: ServerOptions + _onError: (err: Error, browserSocket: net.Socket, head: Buffer, port?: string) => void | null + _ipServers: Record> + _sniPort: number + _sniServer: https.Server + + constructor (_ca: CA, _port: number, _options: ServerOptions) { + this._ca = _ca + this._port = _port + this._options = _options + this._onError = null + this._ipServers = sslIpServers + } + + connect (req: IncomingMessage, browserSocket: net.Socket, head: Buffer) { + // the SNI server requires a hostname, so if the hostname is blank, + // destroy the socket and fail fast + const { hostname } = url.parse(`https://${req.url}`) + + if (!hostname) { + browserSocket.destroy() + + return debug(`Invalid hostname for request url ${req.url}`) + } + + // don't buffer writes - thanks a lot, Nagle + // https://github.com/cypress-io/cypress/issues/3192 + browserSocket.setNoDelay(true) + + debug('Writing browserSocket connection headers %o', { url: req.url, headLength: _.get(head, 'length'), headers: req.headers }) + + browserSocket.on('error', (err: Error) => { + // TODO: shouldn't we destroy the upstream socket here? + // and also vise versa if the upstream socket throws? + // we may get this "for free" though because piping will + // automatically forward the TCP errors...? + + // nothing to do except catch here, the browser has d/c'd + return debug('received error on client browserSocket %o', { + err, url: req.url, + }) + }) + + browserSocket.write('HTTP/1.1 200 OK\r\n') + + if (req.headers['proxy-connection'] === 'keep-alive') { + browserSocket.write('Proxy-Connection: keep-alive\r\n') + browserSocket.write('Connection: keep-alive\r\n') + } + + browserSocket.write('\r\n') + + // if we somehow already have the head here + if (_.get(head, 'length')) { + // then immediately make up the connection + return this._onFirstHeadBytes(req, browserSocket, head) + } + + // else once we get it make the connection later + return browserSocket.once('data', (data) => { + return this._onFirstHeadBytes(req, browserSocket, data) + }) + } + + _onFirstHeadBytes (req: IncomingMessage, browserSocket: net.Socket, head: Buffer) { + // @ts-expect-error + debug('Got first head bytes %o', { url: req.url, head: _.chain(head).invoke('toString').slice(0, 64).join('').value() }) + + browserSocket.pause() + + return this._onServerConnectData(req, browserSocket, head) + } + + _onUpgrade (fn: (req: IncomingMessage, browserSocket: net.Socket, head: Buffer) => void | undefined, req: IncomingMessage, browserSocket: net.Socket, head: Buffer) { + debug('upgrade', req.url) + if (fn) { + return fn.call(this, req, browserSocket, head) + } + } + + _onRequest (fn: (req: IncomingMessage, res: ServerResponse) => void | undefined, req: IncomingMessage, res: ServerResponse) { + const hostPort = hostAndPort(req.url, req.headers, 443) + + req.url = url.format({ + protocol: 'https:', + hostname: hostPort.host, + port: hostPort.port, + }) + req.url + + if (fn) { + return fn.call(this, req, res) + } + } + + _makeConnection (browserSocket: net.Socket, head: Buffer, port: string, hostname: string) { + const onSocket = (err: Error, upstreamSocket: net.Socket) => { + debug('received upstreamSocket callback for request %o', { port, hostname, err }) + + onError = (err: Error) => { + browserSocket.destroy(err) + + if (this._onError) { + return this._onError(err, browserSocket, head, port) + } + } + + if (err) { + return onError(err) + } + + upstreamSocket.setNoDelay(true) + upstreamSocket.on('error', onError) + + browserSocket.emit('upstream-connected', upstreamSocket) + + browserSocket.pipe(upstreamSocket) + upstreamSocket.pipe(browserSocket) + upstreamSocket.write(head) + + return browserSocket.resume() + } + + if (!port) { + port = '443' + } + + // @ts-expect-error + return connect.createRetryingSocket({ port: Number(port), host: hostname }, onSocket) + } + + _onServerConnectData (req: IncomingMessage, browserSocket: net.Socket, head: Buffer) { + let sem: semaphore.Semaphore | undefined + let sslServer: { port: number } | undefined + const firstBytes = head[0] + + const makeConnection = (port: number) => { + debug('Making intercepted connection to %s', port) + + return this._makeConnection(browserSocket, head, port.toString(), 'localhost') + } + + if (!SSL_RECORD_TYPES.includes(firstBytes)) { + // if this isn't an SSL request then go + // ahead and make the connection now + return makeConnection(this._port) + } + + // else spin up the SNI server + const { hostname } = url.parse(`https://${req.url}`) + + sslServer = sslServers[hostname] + + if (sslServer) { + return makeConnection(sslServer.port) + } + + // only be creating one SSL server per hostname at once + if (!(sem = sslSemaphores[hostname])) { + sem = (sslSemaphores[hostname] = semaphore(1)) + } + + return sem.take(async () => { + const leave = () => { + return process.nextTick(() => { + return sem.leave() + }) + } + + sslServer = sslServers[hostname] + + if (sslServer) { + leave() + + return makeConnection(sslServer.port) + } + + let port: number + + try { + try { + port = await this._getPortFor(hostname) + } catch (err) { + debug('Error adding context, deleting certs and regenning %o', { hostname, err }) + + // files on disk can be corrupted, so try again + // @see https://github.com/cypress-io/cypress/issues/8705 + await this._ca.clearDataForHostname(hostname) + + port = await this._getPortFor(hostname) + } + + sslServers[hostname] = { port } + + leave() + + return makeConnection(port) + } catch (e) { + debug('Error making connection %o', { e }) + + browserSocket.destroy(e) + + leave() + + if (this._onError) { + return this._onError(e, browserSocket, head) + } + } + }) + } + + _normalizeKeyAndCert (certPem: string, privateKeyPem: string): { key: string, cert: string } { + return { + key: privateKeyPem, + cert: certPem, + } + } + + async _getCertificatePathsFor (hostname: string): Promise<{ key: string, cert: string }> { + const [certPem, privateKeyPem] = await this._ca.getCertificateKeysForHostname(hostname) + + return this._normalizeKeyAndCert(certPem, privateKeyPem) + } + + async _generateMissingCertificates (hostname: string): Promise<{ key: string, cert: string }> { + const [certPem, privateKeyPem] = await this._ca.generateServerCertificateKeys(hostname) + + return this._normalizeKeyAndCert(certPem, privateKeyPem) + } + + async _getPortFor (hostname: string): Promise { + let data: { + key: string + cert: string + } + + try { + data = await this._getCertificatePathsFor(hostname) + } catch (err) { + data = await this._generateMissingCertificates(hostname) + } + + if (net.isIP(hostname)) { + return this._getServerPortForIp(hostname, data) + } + + this._sniServer.addContext(hostname, data) + + return this._sniPort + } + + _listenHttpsServer (data: { key?: string, cert?: string }): Promise<{ server: https.Server, port: number }> { + return new Promise((resolve, reject) => { + const server = https.createServer({ + ...data, + ...httpUtils.lenientOptions, + }) + + allowDestroy(server) + + server.once('error', reject) + server.on('upgrade', this._onUpgrade.bind(this, this._options.onUpgrade)) + server.on('request', this._onRequest.bind(this, this._options.onRequest)) + + return server.listen(0, '127.0.0.1', () => { + const { + port, + } = server.address() as AddressInfo + + server.removeListener('error', reject) + server.on('error', onError) + + return 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 + async _getServerPortForIp (ip: string, data: { key: string, cert: string }): Promise { + const sslServer = sslIpServers[ip] + + if (sslServer) { + return (sslServer.address() as AddressInfo).port + } + + const { server, port } = await this._listenHttpsServer(data) + + sslIpServers[ip] = server + + debug('Created IP HTTPS Proxy Server', { port, ip }) + + return port + } + + async listen () { + this._onError = this._options.onError + + const { server, port } = await this._listenHttpsServer({}) + + this._sniPort = port + this._sniServer = server + + return debug('Created SNI HTTPS Proxy Server', { port }) + } + + close = async (): Promise => { + const close = async () => { + const servers = _.values(sslIpServers).concat(this._sniServer) + + await Promise.all(servers.map((server) => { + return new Promise((resolve) => { + server.destroy((err: Error | undefined) => { + if (err) { + onError(err) + } + + resolve() + }) + }) + })) + } + + try { + await close() + } finally { + reset() + } + + return undefined + } +} + +export function reset () { + sslServers = {} + sslIpServers = {} +} + +export async function create (ca: CA, port: number, options: ServerOptions) { + const srv = new Server(ca, port, options) + + await srv.listen() + + return srv +} diff --git a/packages/https-proxy/lib/util/parse.js b/packages/https-proxy/lib/util/parse.js deleted file mode 100644 index e3c2ee86fb1..00000000000 --- a/packages/https-proxy/lib/util/parse.js +++ /dev/null @@ -1,49 +0,0 @@ -const url = require('url') - -module.exports = { - parseHost (hostString, defaultPort) { - let m - - m = hostString.match(/^http:\/\/(.*)/) - - if (m) { - const parsedUrl = url.parse(hostString) - - return { - host: parsedUrl.hostname, - port: parsedUrl.port, - } - } - - const hostPort = hostString.split(':') - const host = hostPort[0] - const port = hostPort.length === 2 ? +hostPort[1] : defaultPort - - return { - host, - port, - } - }, - - hostAndPort (reqUrl, headers, defaultPort) { - let m - const { - host, - } = headers - - const hostPort = this.parseHost(host, defaultPort) - - // this handles paths which include the full url. This could happen if it's a proxy - m = reqUrl.match(/^http:\/\/([^\/]*)\/?(.*)$/) - - if (m) { - const parsedUrl = url.parse(reqUrl) - - hostPort.host = parsedUrl.hostname - hostPort.port = parsedUrl.port - reqUrl = parsedUrl.path - } - - return hostPort - }, -} diff --git a/packages/https-proxy/lib/util/parse.ts b/packages/https-proxy/lib/util/parse.ts new file mode 100644 index 00000000000..d13bd80e837 --- /dev/null +++ b/packages/https-proxy/lib/util/parse.ts @@ -0,0 +1,47 @@ +import { parse } from 'url' + +export function parseHost (hostString: string, defaultPort: number) { + let m + + m = hostString.match(/^http:\/\/(.*)/) + + if (m) { + const parsedUrl = parse(hostString) + + return { + host: parsedUrl.hostname, + port: parsedUrl.port, + } + } + + const hostPort = hostString.split(':') + const host = hostPort[0] + const port = hostPort.length === 2 ? +hostPort[1] : defaultPort + + return { + host, + port, + } +} + +export function hostAndPort (reqUrl: string, headers: any, defaultPort: number) { + let m + const { + host, + } = headers + + const hostPort = parseHost(host, defaultPort) + + // this handles paths which include the full url. This could happen if it's a proxy + m = reqUrl.match(/^http:\/\/([^\/]*)\/?(.*)$/) + + if (m) { + const parsedUrl = parse(reqUrl) + + hostPort.host = parsedUrl.hostname + hostPort.port = parsedUrl.port + reqUrl = parsedUrl.path + } + + return hostPort +} diff --git a/packages/https-proxy/package.json b/packages/https-proxy/package.json index 6e46d8e674c..31a706ab830 100644 --- a/packages/https-proxy/package.json +++ b/packages/https-proxy/package.json @@ -2,20 +2,22 @@ "name": "@packages/https-proxy", "version": "0.0.0-development", "private": true, - "main": "index.js", + "main": "cjs/index.js", "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build-prod": "yarn build", + "build:cjs": "rimraf cjs && tsc -p tsconfig.cjs.json", + "build:esm": "rimraf esm && tsc -p tsconfig.esm.json", + "check-ts": "tsc -p tsconfig.cjs.json --noEmit", "clean-deps": "rimraf node_modules", - "https": "node https.js", "lint": "eslint --ext .js,.jsx,.ts,.tsx,.json, .", "regenerate:certs": "cd ./test/helpers/certs && ./regenerate-certs.sh", - "start": "node index.js", "test": "yarn test-unit", - "test-debug": "yarn test-unit --inspect-brk=5566", - "test-unit": "cross-env NODE_ENV=test mocha --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json", - "test-watch": "cross-env NODE_ENV=test mocha --watch" + "test-debug": "vitest --inspect-brk --test-timeout=0 --hook-timeout=0", + "test-unit": "vitest run", + "test-watch": "vitest --watch" }, "dependencies": { - "bluebird": "3.5.3", "debug": "^4.3.4", "fs-extra": "9.1.0", "lodash": "^4.17.21", @@ -27,18 +29,15 @@ "@cypress/request": "^3.0.9", "@cypress/request-promise": "^5.0.0", "@packages/network": "0.0.0-development", - "@packages/ts": "0.0.0-development", - "chai": "3.5.0", - "cross-env": "7.0.3", - "mocha": "3.5.3", - "sinon": "1.17.7", - "sinon-as-promised": "4.0.3", - "sinon-chai": "3.7.0", - "ssl-root-cas": "1.3.1", - "supertest": "4.0.2" + "@types/semaphore": "1.1.4", + "tsx": "^4.20.6", + "vitest": "^3.2.4" }, "files": [ - "lib" + "cjs/*", + "esm/*" ], + "types": "cjs/index.d.ts", + "module": "esm/index.js", "nx": {} } diff --git a/packages/https-proxy/test/helpers/certs.js b/packages/https-proxy/test/helpers/certs.js deleted file mode 100644 index 9a3b91a48e3..00000000000 --- a/packages/https-proxy/test/helpers/certs.js +++ /dev/null @@ -1,14 +0,0 @@ -const fs = require('fs') -const path = require('path') -const sslRootCas = require('ssl-root-cas') - -sslRootCas -.inject() -.addFile(path.join(__dirname, 'certs', 'server', 'my-root-ca.crt.pem')) - -const options = { - key: fs.readFileSync(path.join(__dirname, 'certs', 'server', 'my-server.key.pem')), - cert: fs.readFileSync(path.join(__dirname, 'certs', 'server', 'my-server.crt.pem')), -} - -module.exports = options diff --git a/packages/https-proxy/test/helpers/certs.ts b/packages/https-proxy/test/helpers/certs.ts new file mode 100644 index 00000000000..e2e3f8c9ffc --- /dev/null +++ b/packages/https-proxy/test/helpers/certs.ts @@ -0,0 +1,7 @@ +import fs from 'fs' +import path from 'path' + +export const options = { + key: fs.readFileSync(path.join(__dirname, 'certs', 'server', 'my-server.key.pem')), + cert: fs.readFileSync(path.join(__dirname, 'certs', 'server', 'my-server.crt.pem')), +} diff --git a/packages/https-proxy/test/helpers/certs/create-certs.ts b/packages/https-proxy/test/helpers/certs/create-certs.ts new file mode 100644 index 00000000000..c66262e28ce --- /dev/null +++ b/packages/https-proxy/test/helpers/certs/create-certs.ts @@ -0,0 +1,3 @@ +import { CA } from '../../../lib/ca' + +CA.create(process.env.CA_PATH) diff --git a/packages/https-proxy/test/helpers/certs/regenerate-certs.sh b/packages/https-proxy/test/helpers/certs/regenerate-certs.sh index 316364d39b9..4e40c76397c 100755 --- a/packages/https-proxy/test/helpers/certs/regenerate-certs.sh +++ b/packages/https-proxy/test/helpers/certs/regenerate-certs.sh @@ -15,7 +15,7 @@ CA_PATH=../../../ca rm -rf $CA_PATH # ensure regular root CA exists -node -e "require('@packages/https-proxy/lib/ca').create('$CA_PATH')" +CA_PATH=$CA_PATH tsx create-certs.ts echo "remove and relink test CA pems" for f in ca client server diff --git a/packages/https-proxy/test/helpers/http_server.js b/packages/https-proxy/test/helpers/http_server.js deleted file mode 100644 index cc02233e424..00000000000 --- a/packages/https-proxy/test/helpers/http_server.js +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable - no-console, -*/ -const http = require('http') -const Promise = require('bluebird') - -const srv = http.createServer((req, res) => { - console.log('HTTP SERVER REQUEST URL:', req.url) - console.log('HTTP SERVER REQUEST HEADERS:', req.headers) - - res.setHeader('Content-Type', 'text/html') - res.writeHead(200) - - res.end('http server') -}) - -module.exports = { - srv, - - start () { - return new Promise((resolve) => { - srv.listen(8080, () => { - console.log('server listening on port: 8080') - - resolve(srv) - }) - }) - }, - - stop () { - return new Promise((resolve) => { - srv.close(resolve) - }) - }, -} diff --git a/packages/https-proxy/test/helpers/http_server.ts b/packages/https-proxy/test/helpers/http_server.ts new file mode 100644 index 00000000000..7133aef1f5c --- /dev/null +++ b/packages/https-proxy/test/helpers/http_server.ts @@ -0,0 +1,31 @@ +/* eslint-disable + no-console, +*/ +import http from 'http' +import type { IncomingMessage, ServerResponse } from 'http' + +export const srv = http.createServer((req: IncomingMessage, res: ServerResponse) => { + console.log('HTTP SERVER REQUEST URL:', req.url) + console.log('HTTP SERVER REQUEST HEADERS:', req.headers) + + res.setHeader('Content-Type', 'text/html') + res.writeHead(200) + + res.end('http server') +}) + +export const start = () => { + return new Promise((resolve) => { + srv.listen(8080, () => { + console.log('server listening on port: 8080') + + resolve(srv) + }) + }) +} + +export const stop = () => { + return new Promise((resolve) => { + srv.close(resolve) + }) +} diff --git a/packages/https-proxy/test/helpers/https_server.js b/packages/https-proxy/test/helpers/https_server.js deleted file mode 100644 index 6b3b381c419..00000000000 --- a/packages/https-proxy/test/helpers/https_server.js +++ /dev/null @@ -1,56 +0,0 @@ -/* eslint-disable - no-console, -*/ -const https = require('https') -const Promise = require('bluebird') -const { allowDestroy } = require('@packages/network') -const certs = require('./certs') - -const defaultOnRequest = function (req, res) { - console.log('HTTPS SERVER REQUEST URL:', req.url) - console.log('HTTPS SERVER REQUEST HEADERS:', req.headers) - - res.setHeader('Content-Type', 'text/html') - res.writeHead(200) - - res.end('https server') -} - -let servers = [] - -const create = (onRequest) => { - return https.createServer(certs, onRequest != null ? onRequest : defaultOnRequest) -} - -module.exports = { - create, - - start (port, onRequest) { - return new Promise((resolve) => { - const srv = create(onRequest) - - allowDestroy(srv) - - servers.push(srv) - - srv.listen(port, () => { - console.log(`server listening on port: ${port}`) - - resolve(srv) - }) - }) - }, - - stop () { - const stop = (srv) => { - return new Promise((resolve) => { - srv.destroy(resolve) - }) - } - - return Promise.map(servers, stop) - .then(() => { - servers = [] - }) - }, -} diff --git a/packages/https-proxy/test/helpers/https_server.ts b/packages/https-proxy/test/helpers/https_server.ts new file mode 100644 index 00000000000..a3eaaf5f656 --- /dev/null +++ b/packages/https-proxy/test/helpers/https_server.ts @@ -0,0 +1,51 @@ +/* eslint-disable + no-console, +*/ +import https from 'https' +import { allowDestroy } from '@packages/network' +import { options } from './certs' +import type { IncomingMessage, ServerResponse } from 'http' + +const defaultOnRequest = function (req: IncomingMessage, res: ServerResponse) { + console.log('HTTPS SERVER REQUEST URL:', req.url) + console.log('HTTPS SERVER REQUEST HEADERS:', req.headers) + + res.setHeader('Content-Type', 'text/html') + res.writeHead(200) + + res.end('https server') +} + +let servers: https.Server[] = [] + +export const create = (onRequest: (req: IncomingMessage, res: ServerResponse) => void) => { + return https.createServer(options, onRequest != null ? onRequest : defaultOnRequest) +} + +export const start = (port: number, onRequest?: (req: IncomingMessage, res: ServerResponse) => void) => { + return new Promise((resolve) => { + const srv = create(onRequest) + + allowDestroy(srv) + + servers.push(srv) + + srv.listen(port, () => { + console.log(`server listening on port: ${port}`) + + resolve(srv) + }) + }) +} + +export const stop = async () => { + const stopServer = (srv: https.Server) => { + return new Promise((resolve) => { + srv.destroy(resolve) + }) + } + + await Promise.all(servers.map((server) => stopServer(server))) + + servers = [] +} diff --git a/packages/https-proxy/test/helpers/proxy.js b/packages/https-proxy/test/helpers/proxy.js deleted file mode 100644 index 9b116509a4c..00000000000 --- a/packages/https-proxy/test/helpers/proxy.js +++ /dev/null @@ -1,92 +0,0 @@ -/* eslint-disable no-console */ -const { request } = require('../spec_helper') -const { allowDestroy } = require('@packages/network') -const http = require('http') -const path = require('path') -const httpsProxy = require('../../lib/proxy') - -let prx = null - -const pipe = (req, res) => { - return req.pipe(request(req.url)) - .on('error', () => { - console.log('**ERROR**', req.url) - req.statusCode = 500 - - res.end() - }).pipe(res) -} - -const onConnect = (req, socket, head, proxy) => { - return proxy.connect(req, socket, head, { - onDirectConnection (req, socket, head) { - return ['localhost:8444', 'localhost:12344'].includes(req.url) - }, - }) -} - -const onRequest = (req, res) => { - return pipe(req, res) -} - -module.exports = { - reset () { - return httpsProxy.reset() - }, - - start (port) { - prx = http.createServer() - - allowDestroy(prx) - - const dir = path.join(process.cwd(), 'ca') - - return httpsProxy.create(dir, port, { - onUpgrade (req, socket, head) {}, - - onRequest (req, res) { - console.log('ON REQUEST FROM OUTER PROXY', req.url, req.headers, req.method) - - if (req.url.includes('replace')) { - const { - write, - } = res - - res.write = function (chunk) { - chunk = Buffer.from(chunk.toString().replace('https server', 'replaced content')) - - return write.call(this, chunk) - } - - return pipe(req, res) - } - - return pipe(req, res) - }, - }) - .then((proxy) => { - prx.on('request', onRequest) - - prx.on('connect', (req, socket, head) => { - return onConnect(req, socket, head, proxy) - }) - - return new Promise((resolve) => { - prx.listen(port, () => { - prx.proxy = proxy - console.log(`server listening on port: ${port}`) - - resolve(proxy) - }) - }) - }) - }, - - stop () { - return new Promise((resolve) => { - return prx.destroy(resolve) - }).then(() => { - return prx.proxy.close() - }) - }, -} diff --git a/packages/https-proxy/test/helpers/proxy.ts b/packages/https-proxy/test/helpers/proxy.ts new file mode 100644 index 00000000000..10855b7b683 --- /dev/null +++ b/packages/https-proxy/test/helpers/proxy.ts @@ -0,0 +1,91 @@ +/* eslint-disable no-console */ +import request from '@cypress/request-promise' +import { allowDestroy } from '@packages/network' +import http from 'http' +import path from 'path' +import { create as createProxy, reset as resetProxy } from '../../lib/proxy' +import type { IncomingMessage, ServerResponse } from 'http' +import type { Socket } from 'net' +import type { Server } from '../../lib/server' + +let prx = null + +const pipe = (req: IncomingMessage, res: ServerResponse) => { + return req.pipe(request(req.url)) + .on('error', () => { + console.log('**ERROR**', req.url) + req.statusCode = 500 + + res.end() + }).pipe(res) +} + +const onConnect = (req: IncomingMessage, socket: Socket, head: Buffer, proxy: any) => { + return proxy.connect(req, socket, head, { + onDirectConnection (req: IncomingMessage, socket: Socket, head: Buffer) { + return ['localhost:8444', 'localhost:12344'].includes(req.url) + }, + }) +} + +const onRequest = (req: IncomingMessage, res: ServerResponse) => { + return pipe(req, res) +} + +export const reset = () => resetProxy() + +export const start = (port: number): Promise => { + prx = http.createServer() + + allowDestroy(prx) + + const dir = path.join(process.cwd(), 'ca') + + return createProxy(dir, port, { + onUpgrade (req: IncomingMessage, socket: Socket, head: Buffer) {}, + + onRequest (req: IncomingMessage, res: ServerResponse) { + console.log('ON REQUEST FROM OUTER PROXY', req.url, req.headers, req.method) + + if (req.url.includes('replace')) { + const { + write, + } = res + + res.write = function (chunk) { + chunk = Buffer.from(chunk.toString().replace('https server', 'replaced content')) + + return write.call(this, chunk) + } + + return pipe(req, res) + } + + return pipe(req, res) + }, + }) + .then((proxy) => { + prx.on('request', onRequest) + + prx.on('connect', (req, socket, head) => { + return onConnect(req, socket, head, proxy) + }) + + return new Promise((resolve) => { + prx.listen(port, () => { + prx.proxy = proxy + console.log(`server listening on port: ${port}`) + + resolve(proxy) + }) + }) + }) +} + +export const stop = () => { + return new Promise((resolve) => { + return prx.destroy(resolve) + }).then(() => { + return prx.proxy.close() + }) +} diff --git a/packages/https-proxy/test/integration/proxy.spec.ts b/packages/https-proxy/test/integration/proxy.spec.ts new file mode 100644 index 00000000000..e07263de9d2 --- /dev/null +++ b/packages/https-proxy/test/integration/proxy.spec.ts @@ -0,0 +1,389 @@ +import { it, describe, expect, beforeEach, beforeAll, afterEach, afterAll, vi } from 'vitest' +import path from 'path' +import request from '@cypress/request-promise' +import DebugProxy from '@cypress/debugging-proxy' +import https from 'https' +import net from 'net' +import network from '@packages/network' +import { start as startProxy, stop as stopProxy, reset as resetProxy } from '../helpers/proxy' +import { start as startHttpServer, stop as stopHttpServer } from '../helpers/http_server' +import { start as startHttpsServer, stop as stopHttpsServer } from '../helpers/https_server' +import fs from 'fs/promises' +import { Server } from '../../lib/server' + +describe('Proxy', () => { + let proxy: Server + + // clean out the ca directory before and after all tests + beforeAll(async function () { + try { + await fs.rm(path.join(__dirname, '../', '../', 'ca'), { recursive: true }) + } catch (err) { + // if the directory does not exist, we can ignore the error + if (err.code !== 'ENOENT') { + throw err + } + } + }) + + afterAll(async function () { + try { + await fs.rm(path.join(__dirname, '../', '../', 'ca'), { recursive: true }) + } catch (err) { + // if the directory does not exist, we can ignore the error + if (err.code !== 'ENOENT') { + throw err + } + } + }) + + beforeEach(async function () { + await Promise.all([ + startHttpServer(), + startHttpsServer(8443), + startHttpsServer(8444), + ]) + + proxy = await startProxy(3333) + }) + + afterEach(async () => { + await Promise.all([ + stopHttpServer(), + stopHttpsServer(), + stopProxy(), + ]) + }) + + it('can request the googles', async function () { + await Promise.all([ + request({ + strictSSL: false, + proxy: 'http://localhost:3333', + url: 'https://www.google.com', + }), + + request({ + strictSSL: false, + proxy: 'http://localhost:3333', + url: 'https://mail.google.com', + }), + + request({ + strictSSL: false, + proxy: 'http://localhost:3333', + url: 'https://google.com', + }), + ]) + // give some padding to external + // network request + }, 10000) + + it('can call the httpsDirectly without a proxy', async () => { + await request({ + strictSSL: false, + url: 'https://localhost:8443', + secureProtocol: 'TLSv1_2_method', + }) + }) + + it('can boot the httpsServer', async () => { + const html = await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + expect(html).toContain('https server') + }) + + it('yields the onRequest callback', async () => { + const html = await request({ + strictSSL: false, + url: 'https://localhost:8443/replace', + proxy: 'http://localhost:3333', + }) + + expect(html).toContain('replaced content') + }) + + it('closes outgoing connections when client disconnects', async function () { + const connectSpy = vi.spyOn(net, 'connect') + + await request({ + strictSSL: false, + url: 'https://localhost:8444/replace', + proxy: 'http://localhost:3333', + resolveWithFullResponse: true, + }) + + // ensure the outgoing socket created for this connection was destroyed + expect(connectSpy).toHaveBeenCalledOnce() + }) + + it('can boot the httpServer', async () => { + const html = await request({ + strictSSL: false, + url: 'http://localhost:8080/', + proxy: 'http://localhost:3333', + }) + + expect(html).toContain('http server') + }) + + describe('generating certificates', { retry: 4 }, function () { + it('reuses existing certificates', async function () { + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + resetProxy() + + // should not generate missing certificates + let genSpy = vi.spyOn(proxy, '_generateMissingCertificates') + + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + expect(genSpy).not.toHaveBeenCalled() + }) + + // @see https://github.com/cypress-io/cypress/issues/8705 + it('handles errors with reusing existing certificates', async function () { + await proxy._ca.removeAll() + + resetProxy() + let genSpy = vi.spyOn(proxy, '_generateMissingCertificates') + + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + resetProxy() + expect(genSpy).toHaveBeenCalledExactlyOnceWith('localhost') + + const privateKeyPath = proxy._ca.getPrivateKeyPath('localhost') + const key = (await fs.readFile(privateKeyPath)).toString().trim() + + expect(key).toMatch(/^-----BEGIN RSA PRIVATE KEY-----/) + expect(key).toMatch(/-----END RSA PRIVATE KEY-----$/) + + await fs.writeFile(privateKeyPath, 'some random garbage') + + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + expect(genSpy).toHaveBeenCalledTimes(2) + expect(genSpy).toHaveBeenNthCalledWith(1, 'localhost') + expect(genSpy).toHaveBeenNthCalledWith(2, 'localhost') + + const key2 = (await fs.readFile(privateKeyPath)).toString().trim() + + expect(key2).toMatch(/^-----BEGIN RSA PRIVATE KEY-----/) + expect(key2).toMatch(/-----END RSA PRIVATE KEY-----$/) + }) + + // https://github.com/cypress-io/cypress/issues/771 + it('generates certs and can proxy requests for HTTPS requests to IPs', async function () { + let generateMissingCertificatesSpy = vi.spyOn(proxy, '_generateMissingCertificates') + let getServerPortSpy = vi.spyOn(proxy, '_getServerPortForIp') + + await Promise.all([ + startHttpsServer(8445), + proxy._ca.removeAll(), + ]) + + await request({ + strictSSL: false, + url: 'https://127.0.0.1:8445/', + proxy: 'http://localhost:3333', + }) + + // this should not stand up its own https server + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + expect(proxy._ipServers['127.0.0.1']).toBeInstanceOf(https.Server) + expect(getServerPortSpy).toHaveBeenCalledExactlyOnceWith('127.0.0.1', expect.any(Object)) + + expect(generateMissingCertificatesSpy).toHaveBeenCalledTimes(2) + }, 5000) + + // https://github.com/cypress-io/cypress/issues/9220 + it('handles errors with addContext', async function () { + let connectSpy = vi.spyOn(proxy, 'connect') + + vi.spyOn(proxy._sniServer, 'addContext').mockImplementation(() => { + throw new Error('error adding context') + }) + + try { + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + throw new Error('should not reach here') + } catch (err) { + expect(err.message).toMatch(/Client network socket disconnected before secure TLS connection was established/) + // This scenario will cause an error but we should clean + // ensure the outgoing socket created for this connection was destroyed + expect(connectSpy).toHaveBeenCalledOnce() + + const socket = connectSpy.mock.calls[0][1] + + expect(socket.destroyed).toBe(true) + } + }) + }) + + describe('closing', () => { + it('resets sslServers and can reopen', async function () { + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + await stopProxy() + await startProxy(3333) + + // make sure missing certificates are not generated and are reused + let generateMissingCertificatesSpy = vi.spyOn(proxy, '_generateMissingCertificates') + + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + expect(generateMissingCertificatesSpy).not.toHaveBeenCalled() + }) + }) + + describe('with an upstream proxy', () => { + let upstream: DebugProxy + + beforeEach(function () { + vi.unstubAllEnvs() + // PROXY vars should override npm_config vars, so set them to cause failures if they are used + // @see https://github.com/cypress-io/cypress/pull/8295 + vi.stubEnv('npm_config_proxy', 'http://erroneously-used-npm-proxy.invalid') + vi.stubEnv('npm_config_https_proxy', 'http://erroneously-used-npm-proxy.invalid') + vi.stubEnv('npm_config_noproxy', 'just,some,nonsense') + vi.stubEnv('NO_PROXY', '') + vi.stubEnv('HTTP_PROXY', 'http://localhost:2222') + vi.stubEnv('HTTPS_PROXY', 'http://localhost:2222') + + upstream = new DebugProxy({ + keepRequests: true, + }) + + return upstream.start(2222) + }) + + afterEach(async function () { + await upstream.stop() + }) + + it('passes a request to an https server through the upstream', async function () { + upstream._onConnect = function (domain, port) { + expect(domain).toEqual('localhost') + expect(port).toEqual('8444') + + return true + } + + const res = await request({ + strictSSL: false, + url: 'https://localhost:8444/', + proxy: 'http://localhost:3333', + }) + + expect(res).toContain('https server') + }) + + it('uses HTTP basic auth when provided', async function () { + upstream.setAuth({ + username: 'foo', + password: 'bar', + }) + + upstream._onConnect = function (domain, port) { + expect(domain).toEqual('localhost') + expect(port).toEqual('8444') + + return true + } + + vi.stubEnv('HTTP_PROXY', 'http://foo:bar@localhost:2222') + vi.stubEnv('HTTPS_PROXY', 'http://foo:bar@localhost:2222') + + const res = await request({ + strictSSL: false, + url: 'https://localhost:8444/', + proxy: 'http://localhost:3333', + }) + + expect(res).toContain('https server') + }) + + it('closes outgoing connections when client disconnects', async function () { + const connectSpy = vi.spyOn(net, 'connect') + + await request({ + strictSSL: false, + url: 'https://localhost:8444/replace', + proxy: 'http://localhost:3333', + resolveWithFullResponse: true, + forever: false, + }) + + // ensure the outgoing socket created for this connection was destroyed + expect(connectSpy).toHaveBeenCalledOnce() + }) + + // https://github.com/cypress-io/cypress/issues/4257 + it('passes through to SNI when it is intercepted and not through proxy', async function () { + const createSocket = vi.spyOn(network.connect, 'createRetryingSocket').mockImplementation((_, cb) => { + cb(new Error('stub')) + }) + const createProxyConn = vi.spyOn(network.agent.httpsAgent, 'createUpstreamProxyConnection') + + try { + await request({ + strictSSL: false, + url: 'https://localhost:8443', + proxy: 'http://localhost:3333', + resolveWithFullResponse: true, + forever: false, + }) + + throw new Error('should not succeed') + } catch (err) { + expect(err.message).toContain('Client network socket disconnected before secure TLS connection was established') + + expect(createProxyConn).not.toHaveBeenCalled() + + expect(createSocket).toHaveBeenCalledWith({ + port: proxy._sniPort, + host: 'localhost', + }, expect.any(Function)) + } + }) + }) +}) diff --git a/packages/https-proxy/test/integration/proxy_spec.js b/packages/https-proxy/test/integration/proxy_spec.js deleted file mode 100644 index 15268c09e04..00000000000 --- a/packages/https-proxy/test/integration/proxy_spec.js +++ /dev/null @@ -1,386 +0,0 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - -const { request, expect } = require('../spec_helper') -const DebugProxy = require('@cypress/debugging-proxy') -const https = require('https') -const net = require('net') -const network = require('@packages/network') -const Promise = require('bluebird') -const proxy = require('../helpers/proxy') -const httpServer = require('../helpers/http_server') -const httpsServer = require('../helpers/https_server') -const fs = require('fs').promises - -describe('Proxy', () => { - beforeEach(function () { - return Promise.join( - httpServer.start(), - - httpsServer.start(8443), - - httpsServer.start(8444), - - proxy.start(3333) - .then((proxy1) => { - this.proxy = proxy1 - }), - ) - }) - - afterEach(() => { - return Promise.join( - httpServer.stop(), - httpsServer.stop(), - proxy.stop(), - ) - }) - - it('can request the googles', function () { - // give some padding to external - // network request - this.timeout(10000) - - return Promise.all([ - request({ - strictSSL: false, - proxy: 'http://localhost:3333', - url: 'https://www.google.com', - }), - - request({ - strictSSL: false, - proxy: 'http://localhost:3333', - url: 'https://mail.google.com', - }), - - request({ - strictSSL: false, - proxy: 'http://localhost:3333', - url: 'https://google.com', - }), - ]) - }) - - it('can call the httpsDirectly without a proxy', () => { - return request({ - strictSSL: false, - url: 'https://localhost:8443', - }) - }) - - it('can boot the httpsServer', () => { - return request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }) - .then((html) => { - expect(html).to.include('https server') - }) - }) - - it('yields the onRequest callback', () => { - return request({ - strictSSL: false, - url: 'https://localhost:8443/replace', - proxy: 'http://localhost:3333', - }) - .then((html) => { - expect(html).to.include('replaced content') - }) - }) - - it('closes outgoing connections when client disconnects', async function () { - this.sandbox.spy(net, 'connect') - - await request({ - strictSSL: false, - url: 'https://localhost:8444/replace', - proxy: 'http://localhost:3333', - resolveWithFullResponse: true, - }) - - // ensure the outgoing socket created for this connection was destroyed - expect(net.connect).calledOnce - - const socket = net.connect.getCalls()[0].returnValue - - // sometimes the close event happens before we can attach the listener, causing this test to flake - if (!socket.destroyed || !socket.readyState === 'closed') { - await new Promise((resolve) => { - socket.on('close', () => { - expect(socket.destroyed).to.be.true - - resolve() - }) - }) - } - }) - - it('can boot the httpServer', () => { - return request({ - strictSSL: false, - url: 'http://localhost:8080/', - proxy: 'http://localhost:3333', - }) - - .then((html) => { - expect(html).to.include('http server') - }) - }) - - context('generating certificates', function () { - this.retries(4) - - it('reuses existing certificates', function () { - return request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }) - .then(() => { - proxy.reset() - - // force this to reject if its called - this.sandbox.stub(this.proxy, '_generateMissingCertificates').rejects(new Error('should not call')) - - return request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }) - }) - }) - - // @see https://github.com/cypress-io/cypress/issues/8705 - it('handles errors with reusing existing certificates', async function () { - await this.proxy._ca.removeAll() - - proxy.reset() - const genSpy = this.sandbox.spy(this.proxy, '_generateMissingCertificates') - - await request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }) - - proxy.reset() - expect(genSpy).to.be.calledWith('localhost').and.calledOnce - - const privateKeyPath = this.proxy._ca.getPrivateKeyPath('localhost') - const key = (await fs.readFile(privateKeyPath)).toString().trim() - - expect(key).to.match(/^-----BEGIN RSA PRIVATE KEY-----/) - .and.match(/-----END RSA PRIVATE KEY-----$/) - - await fs.writeFile(privateKeyPath, 'some random garbage') - - await request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }) - - expect(genSpy).to.always.have.been.calledWith('localhost').and.calledTwice - - const key2 = (await fs.readFile(privateKeyPath)).toString().trim() - - expect(key2).to.match(/^-----BEGIN RSA PRIVATE KEY-----/) - .and.match(/-----END RSA PRIVATE KEY-----$/) - }) - - // https://github.com/cypress-io/cypress/issues/771 - it('generates certs and can proxy requests for HTTPS requests to IPs', function () { - this.timeout(5000) - - this.sandbox.spy(this.proxy, '_generateMissingCertificates') - this.sandbox.spy(this.proxy, '_getServerPortForIp') - - return Promise.all([ - httpsServer.start(8445), - this.proxy._ca.removeAll(), - ]) - .then(() => { - return request({ - strictSSL: false, - url: 'https://127.0.0.1:8445/', - proxy: 'http://localhost:3333', - }) - }).then(() => { - // this should not stand up its own https server - return request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }) - }).then(() => { - expect(this.proxy._ipServers['127.0.0.1']).to.be.an.instanceOf(https.Server) - expect(this.proxy._getServerPortForIp).to.be.calledWith('127.0.0.1').and.be.calledOnce - - expect(this.proxy._generateMissingCertificates).to.be.calledTwice - }) - }) - - // https://github.com/cypress-io/cypress/issues/9220 - it('handles errors with addContext', async function () { - this.sandbox.spy(this.proxy, 'connect') - this.sandbox.stub(this.proxy._sniServer, 'addContext').throws(new Error('error adding context')) - - return request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }).catch(() => { - // This scenario will cause an error but we should clean - // ensure the outgoing socket created for this connection was destroyed - expect(this.proxy.connect).calledOnce - const socket = this.proxy.connect.getCalls()[0].args[1] - - expect(socket.destroyed).to.be.true - }) - }) - }) - - context('closing', () => { - it('resets sslServers and can reopen', function () { - return request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }) - .then(() => { - return proxy.stop() - }).then(() => { - return proxy.start(3333) - }).then(() => { - // force this to reject if its called - this.sandbox.stub(this.proxy, '_generateMissingCertificates').rejects(new Error('should not call')) - - return request({ - strictSSL: false, - url: 'https://localhost:8443/', - proxy: 'http://localhost:3333', - }) - }) - }) - }) - - context('with an upstream proxy', () => { - beforeEach(function () { - // PROXY vars should override npm_config vars, so set them to cause failures if they are used - // @see https://github.com/cypress-io/cypress/pull/8295 - process.env.npm_config_proxy = process.env.npm_config_https_proxy = 'http://erroneously-used-npm-proxy.invalid' - process.env.npm_config_noproxy = 'just,some,nonsense' - - process.env.NO_PROXY = '' - process.env.HTTP_PROXY = process.env.HTTPS_PROXY = 'http://localhost:2222' - - this.upstream = new DebugProxy({ - keepRequests: true, - }) - - return this.upstream.start(2222) - }) - - it('passes a request to an https server through the upstream', function () { - this.upstream._onConnect = function (domain, port) { - expect(domain).to.eq('localhost') - expect(port).to.eq('8444') - - return true - } - - return request({ - strictSSL: false, - url: 'https://localhost:8444/', - proxy: 'http://localhost:3333', - }).then((res) => { - expect(res).to.contain('https server') - }) - }) - - it('uses HTTP basic auth when provided', function () { - this.upstream.setAuth({ - username: 'foo', - password: 'bar', - }) - - this.upstream._onConnect = function (domain, port) { - expect(domain).to.eq('localhost') - expect(port).to.eq('8444') - - return true - } - - process.env.HTTP_PROXY = process.env.HTTPS_PROXY = 'http://foo:bar@localhost:2222' - - return request({ - strictSSL: false, - url: 'https://localhost:8444/', - proxy: 'http://localhost:3333', - }).then((res) => { - expect(res).to.contain('https server') - }) - }) - - it('closes outgoing connections when client disconnects', async function () { - this.sandbox.spy(net, 'connect') - - await request({ - strictSSL: false, - url: 'https://localhost:8444/replace', - proxy: 'http://localhost:3333', - resolveWithFullResponse: true, - forever: false, - }) - - // ensure the outgoing socket created for this connection was destroyed - expect(net.connect).calledOnce - const socket = net.connect.getCalls()[0].returnValue - - // sometimes the close event happens before we can attach the listener, causing this test to flake - if (!socket.destroyed || !socket.readyState === 'closed') { - await new Promise((resolve) => { - socket.on('close', () => { - expect(socket.destroyed).to.be.true - - resolve() - }) - }) - } - }) - - // https://github.com/cypress-io/cypress/issues/4257 - it('passes through to SNI when it is intercepted and not through proxy', function () { - const createSocket = this.sandbox.stub(network.connect, 'createRetryingSocket').callsArgWith(1, new Error('stub')) - const createProxyConn = this.sandbox.spy(network.agent.httpsAgent, 'createUpstreamProxyConnection') - - return request({ - strictSSL: false, - url: 'https://localhost:8443', - proxy: 'http://localhost:3333', - resolveWithFullResponse: true, - forever: false, - }) - .then(() => { - throw new Error('should not succeed') - }).catch({ message: 'Error: Client network socket disconnected before secure TLS connection was established' }, () => { - expect(createProxyConn).to.not.be.called - - expect(createSocket).to.be.calledWith({ - port: this.proxy._sniPort, - host: 'localhost', - }) - }) - }) - - return afterEach(function () { - this.upstream.stop() - delete process.env.HTTP_PROXY - delete process.env.HTTPS_PROXY - - delete process.env.NO_PROXY - }) - }) -}) diff --git a/packages/https-proxy/test/mocha.opts b/packages/https-proxy/test/mocha.opts deleted file mode 100644 index 96de2086b37..00000000000 --- a/packages/https-proxy/test/mocha.opts +++ /dev/null @@ -1,5 +0,0 @@ -test/unit -test/integration ---reporter spec ---compilers ts:@packages/ts/register ---recursive diff --git a/packages/https-proxy/test/spec_helper.js b/packages/https-proxy/test/spec_helper.js deleted file mode 100644 index bb785dd7a6a..00000000000 --- a/packages/https-proxy/test/spec_helper.js +++ /dev/null @@ -1,20 +0,0 @@ -const chai = require('chai') -const sinon = require('sinon') -const Promise = require('bluebird') -const sinonChai = require('sinon-chai') -const request = require('@cypress/request-promise') -const supertest = require('supertest') - -require('sinon-as-promised')(Promise) - -chai.use(sinonChai) - -beforeEach(function () { - this.sandbox = sinon.sandbox.create() -}) - -afterEach(function () { - return this.sandbox.restore() -}) - -module.exports = { request, sinon, supertest, expect: chai.expect } diff --git a/packages/https-proxy/test/unit/ca.spec.ts b/packages/https-proxy/test/unit/ca.spec.ts new file mode 100644 index 00000000000..c462680badd --- /dev/null +++ b/packages/https-proxy/test/unit/ca.spec.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import os from 'os' +import fs from 'fs-extra' +import path from 'path' +import { CA } from '../../lib/ca' + +vi.mock('fs-extra', async (importActual) => { + const actual = await importActual() + + return { + default: { + // @ts-expect-error + ...actual.default, + readFile: vi.fn(), + outputFile: vi.fn(), + remove: vi.fn(), + stat: vi.fn(), + }, + } +}) + +// make sure the properties we set on the certificate are reflected +const validateCertificate = (ca: CA) => { + expect(ca).toBeInstanceOf(CA) + + // should represent an instance of a pki.Certificate object + expect(ca.CAcert).toBeInstanceOf(Object) + expect(ca.CAcert.version).toEqual(2) + expect(ca.CAcert.extensions).toBeInstanceOf(Array) + expect(ca.CAcert.extensions).toHaveLength(5) + expect(ca.CAcert.extensions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'basicConstraints', + cA: true, + }), + expect.objectContaining({ + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true, + }), + expect.objectContaining({ + name: 'extKeyUsage', + serverAuth: true, + clientAuth: true, + codeSigning: true, + emailProtection: true, + timeStamping: true, + }), + expect.objectContaining({ + name: 'nsCertType', + client: true, + server: true, + email: true, + objsign: true, + sslCA: true, + emailCA: true, + objCA: true, + }), + expect.objectContaining({ + name: 'subjectKeyIdentifier', + }), + ])) + + expect(ca.CAcert.issuer.attributes).toBeInstanceOf(Array) + expect(ca.CAcert.issuer.attributes).toHaveLength(6) + expect(ca.CAcert.issuer.attributes).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'commonName', + value: 'CypressProxyCA', + }), expect.objectContaining({ + name: 'countryName', + value: 'Internet', + }), expect.objectContaining({ + shortName: 'ST', + value: 'Internet', + }), expect.objectContaining({ + name: 'localityName', + value: 'Internet', + }), expect.objectContaining({ + name: 'organizationName', + value: 'Cypress.io', + }), expect.objectContaining({ + shortName: 'OU', + value: 'CA', + }), + ])) + + expect(ca.CAcert.subject.attributes).toBeInstanceOf(Array) + expect(ca.CAcert.subject.attributes).toHaveLength(6) + expect(ca.CAcert.subject.attributes).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'commonName', + value: 'CypressProxyCA', + }), expect.objectContaining({ + name: 'countryName', + value: 'Internet', + }), expect.objectContaining({ + shortName: 'ST', + value: 'Internet', + }), expect.objectContaining({ + name: 'localityName', + value: 'Internet', + }), expect.objectContaining({ + name: 'organizationName', + value: 'Cypress.io', + }), expect.objectContaining({ + shortName: 'OU', + value: 'CA', + }), + ])) + + expect(ca.CAcert.validity.notBefore).toBeInstanceOf(Date) + expect(ca.CAcert.validity.notAfter).toBeInstanceOf(Date) + + // cert should be valid for 10 years + expect(ca.CAcert.validity.notAfter.getFullYear()).toEqual(ca.CAcert.validity.notBefore.getFullYear() + 10) + + expect(ca.CAcert.serialNumber).toEqual(expect.any(String)) + + // should represent an instance of a pki.rsa.KeyPair object + expect(ca.CAkeys).toBeInstanceOf(Object) + expect(ca.CAkeys).toHaveProperty('privateKey') + expect(ca.CAkeys).toHaveProperty('publicKey') +} + +describe('lib/ca', () => { + let tmpDir: string + + beforeEach(async () => { + vi.useFakeTimers() + tmpDir = path.join(os.tmpdir(), 'cy-ca') + vi.mocked(fs.outputFile).mockClear() + vi.mocked(fs.remove).mockClear() + vi.mocked(fs.readFile).mockClear() + vi.mocked(fs.remove).mockClear() + vi.mocked(fs.readFile).mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('#generateServerCertificateKeys', () => { + it('generates certs for each host', async function () { + const ca = await CA.create(tmpDir) + + const [certPem, keyPrivatePem] = await ca.generateServerCertificateKeys('www.cypress.io') + + expect(certPem).to.include('-----BEGIN CERTIFICATE-----') + + expect(keyPrivatePem).to.include('-----BEGIN RSA PRIVATE KEY-----') + }) + }) + + describe('.create', () => { + it('loads existing certs and keys if version matches CA_VERSIONand certs can be loaded from disk', async function () { + // have some mock certs and keys for the tests as a string + const MOCK_CERT_PEM = `-----BEGIN CERTIFICATE-----\r\nMIIEADCCAuigAwIBAgIQ6MJNvbbXbCX7WclKcbjYeDANBgkqhkiG9w0BAQsFADB0\r\nMRcwFQYDVQQDEw5DeXByZXNzUHJveHlDQTERMA8GA1UEBhMISW50ZXJuZXQxETAP\r\nBgNVBAgTCEludGVybmV0MREwDwYDVQQHEwhJbnRlcm5ldDETMBEGA1UEChMKQ3lw\r\ncmVzcy5pbzELMAkGA1UECxMCQ0EwHhcNMjUxMDEzMDA1NjA5WhcNMzUxMDEzMDA1\r\nNjA5WjB0MRcwFQYDVQQDEw5DeXByZXNzUHJveHlDQTERMA8GA1UEBhMISW50ZXJu\r\nZXQxETAPBgNVBAgTCEludGVybmV0MREwDwYDVQQHEwhJbnRlcm5ldDETMBEGA1UE\r\nChMKQ3lwcmVzcy5pbzELMAkGA1UECxMCQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB\r\nDwAwggEKAoIBAQDKOjGEdnGWigjUxWGSFESJKHL8NtGJiu+yfPp+sQNOlm1xmoGQ\r\n/fqcwVznS5Tg6R04awCmOkJ+vBay5ZlMn7l1ke95HPnHlVdUFd+rIgQ3GIQcoF6n\r\nigop7fl6nHQch5G7plmYl996r5BR9I3V0mnG37W0X6puPx75cniW2o7mswduvraW\r\nw8DY/QrA1AgNquOSrwklJbxbAzwQQSI8qezaVJKlPLdtDbDKtZqYotw+68Emd0ul\r\nml72f4gYFwlLkqUObku+s6X+8QZwsVfVqp4nX1JMHlT2HeoXqZpwVqeyufXT1ptT\r\npTwS0P95+47mHqgKNygtPMUGx7WqCrDWzSPXAgMBAAGjgY0wgYowDAYDVR0TBAUw\r\nAwEB/zALBgNVHQ8EBAMCAvQwOwYDVR0lBDQwMgYIKwYBBQUHAwEGCCsGAQUFBwMC\r\nBggrBgEFBQcDAwYIKwYBBQUHAwQGCCsGAQUFBwMIMBEGCWCGSAGG+EIBAQQEAwIA\r\n9zAdBgNVHQ4EFgQUJuQ4X4StMVeALE08ZtcMiyGT91kwDQYJKoZIhvcNAQELBQAD\r\nggEBALKuo2EZs+X0VFM95PcEYPa2a3F+7jdTOCa7aorbew3jiQQUSWH8RYDkmIg5\r\nDI9r6qq5sBmJfsVdTnxpn6w4DIY3dHd1UkGI+zEgKvPzqHwMUHNJGV3U79/JGfV2\r\nQZKllDQlJVEtNPo0o0TLvbPqAET5Z11gfQ1ZNZ4OBYAoJCZg8UflzVpeMsQCorNG\r\nzMObOnOVXWi6QHSQDkwMI3QVvseK1SfMgqcdgyodbEVJoEeay/B5mlYYvhs8iuSc\r\ncfkl3LZx/NtmetyM2SIVI+M7kkKQfcluOMQcBP3UCZ1/m7RajFbe55koi5EgGeFH\r\n1jg+r2qr6VclijKRfbqR6L4JBdg=\r\n-----END CERTIFICATE-----\r\n` + const MOCK_KEY_PRIVATE_PEM = `-----BEGIN RSA PRIVATE KEY-----\r\nMIIEpAIBAAKCAQEA3/Acf8Fy6ECV88Qsz9fVA61cSK5Ki+wS88SYdz41xFp7ssew\r\nwYZvB9Aqii4vu8/to1AItLTV7I5ZEJEZJvlffK7fp15WMEqlpEHVAb3DoNw5ZAPY\r\n56liRHMi0RPV6t/QsyKTP/LYgNaDt+A9HE/My+E/+VfniOOE36tL9UxD+gipffLt\r\n+17c2oeT3Vzin3K4VuxNGtaFwUKUR+XJWcpGxxa/3GT3F6Xfmy1JTnDaFVGHL6ug\r\nW56z8uDyf8UUNaA8HSo2Ak+2Mc32xS0/+5FUSHdp5J8/ghjvnrRl01qmjnKVNSU8\r\n9wYPdoNw0D2N4uOoV2W8J2F76Gq1qlSO6bM1pwIDAQABAoIBAAcN6M/rd6OyWSbv\r\nMJwxh9/QR75wYx/KRYSRVl43QvlXAluM59gI1JmR6K0mrFFFyQ4ieMu8gJqtl0eq\r\n0niEVYo3dgsvMRbfWx10B3JBGJcKKPKqHlyZ3OMcH2Ynsk7uUwQ5nBrhGwnf+BFE\r\nSpiIOQLZKys/JieNR0PGgSOOjfuj2BoS+RMl30tRFOY/4QZP4oyuG/DND77+Cz/z\r\n7vBvF/78HoXfqbSQfLjT+D7kOvTVwF+jDYCUjMpo9uhQfWEA0a0+E8ZdIfzvtEca\r\nzhY18T0hvcDiq+revXjG9uOh7dPls1INdZazM85asife7GVmVEMmAsTXBlPU5wi0\r\nulqso60CgYEA/nRFSASQi5ySyt6De3PKEWc5GhFBVrA5YANKd2olMKvNbkm6W4TZ\r\n9mvJKxHmc2uTIOaRBAgOOZgNrYX2GgFC38IrNPtYBxTl+YmbFX+n4Z7Rp5s+HMz/\r\nVP6e8BLNaSYBT3Cqj1Nb/qfeqHgGmTs2Wk1qWeonyeoXyTO5fWd2JWMCgYEA4Uxh\r\nreKZr6Uz0BWhga7c74fkOg38CWzyW6OBuizlsxUCkzt4gGOXHOqb2O/pN/f2bBDB\r\nME4aErHngRTqW4vSpLPPsgvAxR1Q/dB8YUpesnO1pTFVIcWyVK/HIfo8Fg8ohNpE\r\nyEeemEVvtXz4nLK/Q8/KqV9KCuuCXucaqG4W0+0CgYEAvqTCm7i/y7pdyR16CW6x\r\ngOSDxeITwC18b1FH47xlbNfrrKwUsikRXS1YpapdrTB2JXpaQFkAv2oLJW1u/ADh\r\n5+AEm0eNppCj1Zih1zOzxrlFf3wyx0VYMIgs8NZFjHhrFuflAkmEbYG8syBqYTgZ\r\n+wJxojhr4z4+4AKfATQZMt0CgYATAdenzNs8Z0qUvo5um2sGRkep4i4mOWvE8Wlr\r\nZIhIcHhUJYtIAZ7pEJ3vUmYxk5jViyBRS/WFKD8os7QF3yj5PjZChh1QQ+XmU+V6\r\na8TLd1mWwy+0drJR1LaPFkZlcgfwFV4CK5Cktg7zl8R9q9LZDLnDSke73hyUlxi3\r\npvoEDQKBgQCgJVyo0p3x8bQe8RLKHb6la710I12Px4+sujImdG5+Spqrxyq5ii36\r\nbj/U3txxbqRg8RIzBZ1Zvde8TKt7RyhFqpkCfBQkqd/5/zriHOQZ7lgSNtjq5cYB\r\nEQ/JzKUoa0lAEuAxWQ7eSs8qy+Q36VphSTcNAqCt7rFhWmuASJVObg==\r\n-----END RSA PRIVATE KEY-----\r\n` + const MOCK_KEY_PUBLIC_PEM = `-----BEGIN PUBLIC KEY-----\r\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3/Acf8Fy6ECV88Qsz9fV\r\nA61cSK5Ki+wS88SYdz41xFp7ssewwYZvB9Aqii4vu8/to1AItLTV7I5ZEJEZJvlf\r\nfK7fp15WMEqlpEHVAb3DoNw5ZAPY56liRHMi0RPV6t/QsyKTP/LYgNaDt+A9HE/M\r\ny+E/+VfniOOE36tL9UxD+gipffLt+17c2oeT3Vzin3K4VuxNGtaFwUKUR+XJWcpG\r\nxxa/3GT3F6Xfmy1JTnDaFVGHL6ugW56z8uDyf8UUNaA8HSo2Ak+2Mc32xS0/+5FU\r\nSHdp5J8/ghjvnrRl01qmjnKVNSU89wYPdoNw0D2N4uOoV2W8J2F76Gq1qlSO6bM1\r\npwIDAQAB\r\n-----END PUBLIC KEY-----\r\n` + + // @ts-expect-error + vi.mocked(fs.readFile).mockImplementation((pathToRead: string) => { + if (pathToRead === `${tmpDir}/ca_version.txt`) { + // return version 1 to match current CA_VERSION + return Promise.resolve('1') + } + + if (pathToRead === `${tmpDir}/certs/ca.pem`) { + return Promise.resolve(MOCK_CERT_PEM) + } + + if (pathToRead === `${tmpDir}/keys/ca.private.key`) { + return Promise.resolve(MOCK_KEY_PRIVATE_PEM) + } + + if (pathToRead === `${tmpDir}/keys/ca.public.key`) { + return Promise.resolve(MOCK_KEY_PUBLIC_PEM) + } + + return Promise.reject(new Error(`file not found: ${pathToRead}. Did you remember to mock it?`)) + }) + + const ca = await CA.create(tmpDir) + + validateCertificate(ca) + + // should not attempt to write the certs and keys if discovering cert succeeds version check and can load certs from disk + expect(fs.outputFile).not.toHaveBeenCalledWith(`${tmpDir}/certs/ca.pem`, expect.any(String)) + expect(fs.outputFile).not.toHaveBeenCalledWith(`${tmpDir}/keys/ca.private.key`, expect.any(String)) + expect(fs.outputFile).not.toHaveBeenCalledWith(`${tmpDir}/keys/ca.public.key`, expect.any(String)) + expect(fs.outputFile).not.toHaveBeenCalledWith(`${tmpDir}/ca_version.txt`, '1') + }) + + it('clears out CA folder if no ca_version.txt is found and generates certs and keys', async function () { + vi.mocked(fs.readFile).mockImplementation((pathToRead: string) => { + // mock failing of loading anything certificate related + + return Promise.reject(new Error(`file not found: ${pathToRead}`)) + }) + + const ca = await CA.create(tmpDir) + + validateCertificate(ca) + + // if discovering the version fails or can't discover certs, the temp dir for cy-ca should be removed + expect(fs.remove).toHaveBeenCalledWith(tmpDir) + + // should attempt to write the certs and keys if discovering cert fails version check or otherwise + expect(fs.outputFile).toHaveBeenCalledWith(`${tmpDir}/certs/ca.pem`, expect.any(String)) + expect(fs.outputFile).toHaveBeenCalledWith(`${tmpDir}/keys/ca.private.key`, expect.any(String)) + expect(fs.outputFile).toHaveBeenCalledWith(`${tmpDir}/keys/ca.public.key`, expect.any(String)) + expect(fs.outputFile).toHaveBeenCalledWith(`${tmpDir}/ca_version.txt`, '1') + }) + + it('clears out CA folder with old CA_VERSION and regenerates certs and keys', async function () { + // @ts-expect-error + vi.mocked(fs.readFile).mockImplementation((pathToRead: string) => { + if (pathToRead === `${tmpDir}/ca_version.txt`) { + // return version 0 to return version mismatch + return Promise.resolve('0') + } + + return Promise.reject(new Error(`file not found: ${pathToRead}`)) + }) + + const ca = await CA.create(tmpDir) + + validateCertificate(ca) + + // if discovering the version is a mismatch, the temp dir for cy-ca should be removed + expect(fs.remove).toHaveBeenCalledWith(tmpDir) + + // should attempt to write the certs and keys if discovering cert fails version check or otherwise + expect(fs.outputFile).toHaveBeenCalledWith(`${tmpDir}/certs/ca.pem`, expect.any(String)) + expect(fs.outputFile).toHaveBeenCalledWith(`${tmpDir}/keys/ca.private.key`, expect.any(String)) + expect(fs.outputFile).toHaveBeenCalledWith(`${tmpDir}/keys/ca.public.key`, expect.any(String)) + expect(fs.outputFile).toHaveBeenCalledWith(`${tmpDir}/ca_version.txt`, '1') + }) + }) +}) diff --git a/packages/https-proxy/test/unit/ca_spec.js b/packages/https-proxy/test/unit/ca_spec.js deleted file mode 100644 index 50cd490868c..00000000000 --- a/packages/https-proxy/test/unit/ca_spec.js +++ /dev/null @@ -1,161 +0,0 @@ -/* eslint-disable no-console */ -const { expect } = require('../spec_helper') - -let fs = require('fs-extra') -const path = require('path') -const Promise = require('bluebird') -const CA = require('../../lib/ca') - -fs = Promise.promisifyAll(fs) - -describe('lib/ca', () => { - beforeEach(function () { - this.timeout(5000) - - this.dir = path.join(process.cwd(), 'tmp') - - return fs.ensureDirAsync(this.dir) - .then(() => { - console.time('creating CA') - - return CA.create(this.dir) - .tap(() => { - console.timeEnd('creating CA') - }) - }).then((ca) => { - this.ca = ca - }) - }) - - afterEach(function () { - return fs.removeAsync(this.dir) - }) - - context('#generateServerCertificateKeys', () => { - it('generates certs for each host', async function () { - console.time('generating cert') - - await this.ca.generateServerCertificateKeys('www.cypress.io') - .spread((certPem, keyPrivatePem) => { - console.timeEnd('generating cert') - expect(certPem).to.include('-----BEGIN CERTIFICATE-----') - - expect(keyPrivatePem).to.include('-----BEGIN RSA PRIVATE KEY-----') - }) - }) - }) - - context('.create', () => { - it('returns a new CA instance', function () { - expect(this.ca).to.be.an.instanceof(CA) - }) - - it('creates certs + keys dir', function () { - return Promise.join( - fs.statAsync(path.join(this.dir, 'certs')), - fs.statAsync(path.join(this.dir, 'keys')), - ) - }) - - it('writes certs/ca.pem', function () { - return fs.statAsync(path.join(this.dir, 'certs', 'ca.pem')) - }) - - it('writes keys/ca.private.key', function () { - return fs.statAsync(path.join(this.dir, 'keys', 'ca.private.key')) - }) - - it('writes keys/ca.public.key', function () { - return fs.statAsync(path.join(this.dir, 'keys', 'ca.public.key')) - }) - - it('sets ca.CAcert', function () { - expect(this.ca.CAcert).to.be.an('object') - }) - - it('sets ca.CAkeys', function () { - expect(this.ca.CAkeys).to.be.an('object') - expect(this.ca.CAkeys).to.have.a.property('privateKey') - - expect(this.ca.CAkeys).to.have.a.property('publicKey') - }) - - describe('existing CA folder', () => { - beforeEach(function () { - this.sandbox.spy(CA.prototype, 'loadCA') - this.generateCA = this.sandbox.spy(CA.prototype, 'generateCA') - - return CA.create(this.dir) - .then((ca2) => { - this.ca2 = ca2 - }) - }) - - describe('CA versioning', () => { - beforeEach(function () { - this.removeAll = this.sandbox.spy(CA.prototype, 'removeAll') - }) - - it('clears out CA folder with no ca_version.txt', function () { - expect(this.generateCA).to.not.be.called - - return fs.remove(path.join(this.dir, 'ca_version.txt')) - .then(() => { - return CA.create(this.dir) - }).then(() => { - expect(this.removeAll).to.be.calledOnce - expect(this.generateCA).to.be.calledOnce - }) - }) - - it('clears out CA folder with old ca_version', function () { - expect(this.generateCA).to.not.be.called - - return fs.outputFile(path.join(this.dir, 'ca_version.txt'), '0') - .then(() => { - return CA.create(this.dir) - }).then(() => { - expect(this.removeAll).to.be.calledOnce - expect(this.generateCA).to.be.calledOnce - }) - }) - - it('keeps CA folder with version of at least 1', function () { - expect(this.generateCA).to.not.be.called - - return fs.outputFile(path.join(this.dir, 'ca_version.txt'), '1') - .then(() => { - return CA.create(this.dir) - }).then(() => { - expect(this.removeAll).to.not.be.called - expect(this.generateCA).to.not.be.called - - return fs.outputFile(path.join(this.dir, 'ca_version.txt'), '100') - }).then(() => { - return CA.create(this.dir) - }).then(() => { - expect(this.removeAll).to.not.be.called - expect(this.generateCA).to.not.be.called - }) - }) - }) - - it('calls loadCA and not generateCA', () => { - expect(CA.prototype.loadCA).to.be.calledOnce - - expect(CA.prototype.generateCA).not.to.be.called - }) - - it('sets ca.CAcert', function () { - expect(this.ca2.CAcert).to.be.an('object') - }) - - it('sets ca.CAkeys', function () { - expect(this.ca2.CAkeys).to.be.an('object') - expect(this.ca2.CAkeys).to.have.a.property('privateKey') - - expect(this.ca2.CAkeys).to.have.a.property('publicKey') - }) - }) - }) -}) diff --git a/packages/https-proxy/test/unit/server.spec.ts b/packages/https-proxy/test/unit/server.spec.ts new file mode 100644 index 00000000000..1253784b30e --- /dev/null +++ b/packages/https-proxy/test/unit/server.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import EE from 'events' +import { create as createServer } from '../../lib/server' +import { CA } from '../../lib/ca' +import type { Server } from '../../lib/server' +import type { Socket } from 'net' +import type { IncomingMessage } from 'http' + +vi.mock('../../lib/ca') + +describe('lib/server', () => { + let port: number + let ca: CA + let setup: (options?: any) => Promise + + beforeEach(function () { + vi.unstubAllEnvs() + setup = async (options = {}) => { + ca = await CA.create() + port = 12345 + + return createServer(ca, port, options) + } + }) + + describe('#listen', () => { + it('calls options.onUpgrade with req, socket head', async function () { + const onUpgrade = vi.fn() + + const srv = await setup({ onUpgrade }) + + srv._sniServer.emit('upgrade', 1, 2, 3) + + expect(onUpgrade).toHaveBeenCalledWith(1, 2, 3) + }) + + it('calls options.onRequest with req, res', async function () { + const onRequest = vi.fn() + const req = { url: 'https://www.foobar.com', headers: { host: 'www.foobar.com' } } + const res = {} + + const srv = await setup({ onRequest }) + + srv._sniServer.emit('request', req, res) + + expect(onRequest).toHaveBeenCalledWith(req, res) + }) + + it('calls options.onError with err and port and destroys the client socket', async function () { + const socket = new EE() as Socket + + socket.destroy = vi.fn() + const head: Buffer = Buffer.from('') + + return new Promise(async (resolve) => { + const onError = function (err: Error, socket2: Socket, head2: Buffer, port: string) { + expect(err.message).toEqual('connect ECONNREFUSED 127.0.0.1:8444') + + expect(socket).toEqual(socket2) + expect(head).toEqual(head2) + expect(port).toEqual('8444') + + expect(socket.destroy).toHaveBeenCalledOnce() + + resolve() + } + + const srv = await setup({ onError }) + + srv._makeConnection(socket, head, '8444', 'localhost') + }) + }) + + // https://github.com/cypress-io/cypress/issues/3250 + it('does not crash when an erroneous URL is provided, just destroys socket', function () { + const socket = new EE() as Socket + + socket.destroy = vi.fn() + const head: Buffer = Buffer.from('') + + return new Promise(async (resolve) => { + const onError = function (err: Error, socket2: Socket, head2: Buffer, port: string) { + expect(err.message).toEqual('getaddrinfo ENOTFOUND %7Balgolia_application_id%7D-dsn.algolia.net') + + expect(socket).toEqual(socket2) + expect(head).toEqual(head2) + expect(port).toEqual('443') + + expect(socket.destroy).toHaveBeenCalledOnce() + + resolve() + } + + const srv = await setup({ onError }) + + srv._makeConnection(socket, head, '443', '%7Balgolia_application_id%7D-dsn.algolia.net') + }) + }) + + // https://github.com/cypress-io/cypress/issues/9220 + it('does not crash when a blank URL is parsed and instead only destroys the socket', function () { + const socket = new EE() as Socket + + socket.destroy = vi.fn() + const head: Buffer = Buffer.from('') + + return new Promise(async (resolve) => { + const srv = await setup() + + srv.connect({ url: '%20:443' } as IncomingMessage, socket, head) + expect(socket.destroy).toHaveBeenCalledOnce() + + resolve() + }) + }) + }) +}) diff --git a/packages/https-proxy/test/unit/server_spec.js b/packages/https-proxy/test/unit/server_spec.js deleted file mode 100644 index 8439e422f1a..00000000000 --- a/packages/https-proxy/test/unit/server_spec.js +++ /dev/null @@ -1,112 +0,0 @@ -const { expect } = require('../spec_helper') - -const EE = require('events') -const Server = require('../../lib/server') - -describe('lib/server', () => { - beforeEach(function () { - this.setup = (options = {}) => { - this.ca = {} - this.port = 12345 - - return Server.create(this.ca, this.port, options) - } - }) - - afterEach(() => { - delete process.env.HTTPS_PROXY - - delete process.env.NO_PROXY - }) - - context('#listen', () => { - it('calls options.onUpgrade with req, socket head', function () { - const onUpgrade = this.sandbox.stub() - - return this.setup({ onUpgrade }) - .then((srv) => { - srv._sniServer.emit('upgrade', 1, 2, 3) - - expect(onUpgrade).to.be.calledWith(1, 2, 3) - }) - }) - - it('calls options.onRequest with req, res', function () { - const onRequest = this.sandbox.stub() - const req = { url: 'https://www.foobar.com', headers: { host: 'www.foobar.com' } } - const res = {} - - return this.setup({ onRequest }) - .then((srv) => { - srv._sniServer.emit('request', req, res) - - expect(onRequest).to.be.calledWith(req, res) - }) - }) - - it('calls options.onError with err and port and destroys the client socket', function (done) { - const socket = new EE() - - socket.destroy = this.sandbox.stub() - const head = {} - - const onError = function (err, socket2, head2, port) { - expect(err.message).to.eq('connect ECONNREFUSED 127.0.0.1:8444') - - expect(socket).to.eq(socket2) - expect(head).to.eq(head2) - expect(port).to.eq('8444') - - expect(socket.destroy).to.be.calledOnce - - done() - } - - this.setup({ onError }) - .then((srv) => { - srv._makeConnection(socket, head, '8444', 'localhost') - }) - }) - - // https://github.com/cypress-io/cypress/issues/3250 - it('does not crash when an erroneous URL is provided, just destroys socket', function (done) { - const socket = new EE() - - socket.destroy = this.sandbox.stub() - const head = {} - - const onError = function (err, socket2, head2, port) { - expect(err.message).to.eq('getaddrinfo ENOTFOUND %7Balgolia_application_id%7D-dsn.algolia.net') - - expect(socket).to.eq(socket2) - expect(head).to.eq(head2) - expect(port).to.eq('443') - - expect(socket.destroy).to.be.calledOnce - - done() - } - - this.setup({ onError }) - .then((srv) => { - srv._makeConnection(socket, head, '443', '%7Balgolia_application_id%7D-dsn.algolia.net') - }) - }) - - // https://github.com/cypress-io/cypress/issues/9220 - it('does not crash when a blank URL is parsed and instead only destroys the socket', function (done) { - const socket = new EE() - - socket.destroy = this.sandbox.stub() - const head = {} - - this.setup() - .then((srv) => { - srv.connect({ url: '%20:443' }, socket, head) - expect(socket.destroy).to.be.calledOnce - - done() - }) - }) - }) -}) diff --git a/packages/https-proxy/tsconfig.cjs.json b/packages/https-proxy/tsconfig.cjs.json new file mode 100644 index 00000000000..1a01315ea38 --- /dev/null +++ b/packages/https-proxy/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./lib", + "outDir": "./cjs", + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": true + } +} \ No newline at end of file diff --git a/packages/https-proxy/tsconfig.esm.json b/packages/https-proxy/tsconfig.esm.json new file mode 100644 index 00000000000..f6dd3892038 --- /dev/null +++ b/packages/https-proxy/tsconfig.esm.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./lib", + "outDir": "./esm", + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "declaration": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/packages/https-proxy/tsconfig.json b/packages/https-proxy/tsconfig.json new file mode 100644 index 00000000000..7145498f910 --- /dev/null +++ b/packages/https-proxy/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": [ + "lib" + ], + "compilerOptions": { + "allowJs": false, + "noImplicitAny": true, + "noImplicitReturns": false, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/packages/https-proxy/vitest.config.ts b/packages/https-proxy/vitest.config.ts new file mode 100644 index 00000000000..6bbcdbea532 --- /dev/null +++ b/packages/https-proxy/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + env: { + // disables SSL certificate verification for testing purposes + // This is needed to allow self-signed certificates + // which are needed for the proxy.spec.ts tests + NODE_TLS_REJECT_UNAUTHORIZED: '0', + }, + fileParallelism: false, + include: ['test/**/*.spec.ts'], + globals: true, + environment: 'node', + }, +}) diff --git a/packages/network/package.json b/packages/network/package.json index 90f788153b3..c0a5c80578a 100644 --- a/packages/network/package.json +++ b/packages/network/package.json @@ -32,7 +32,6 @@ "@cypress/debugging-proxy": "2.0.1", "@cypress/request": "^3.0.9", "@cypress/request-promise": "^5.0.0", - "@packages/https-proxy": "0.0.0-development", "@packages/network-tools": "0.0.0-development", "@packages/socket": "0.0.0-development", "@types/concat-stream": "1.6.0", @@ -48,6 +47,5 @@ "esm" ], "types": "./cjs/index.d.ts", - "module": "esm/index.js", - "nx": {} + "module": "esm/index.js" } diff --git a/packages/network/vitest.config.ts b/packages/network/vitest.config.ts index 264c055174a..7f42e4975f7 100644 --- a/packages/network/vitest.config.ts +++ b/packages/network/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ env: { // disables SSL certificate verification for testing purposes // This is needed to allow self-signed certificates - // which are needed for the agent.spec.ts tests + // which are needed for the proxy.spec.ts tests NODE_TLS_REJECT_UNAUTHORIZED: '0', }, include: ['test/**/*.spec.ts'], diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index 51e709bae84..6af9c4367dd 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -12,7 +12,7 @@ import _ from 'lodash' import type { AddressInfo } from 'net' import url from 'url' import la from 'lazy-ass' -import httpsProxy from '@packages/https-proxy' +import { createProxy as createHttpsProxy } from '@packages/https-proxy' import { getRoutesForRequest, netStubbingState, NetStubbingState } from '@packages/net-stubbing' import { agent, clientCertificates, httpUtils, concatStream } from '@packages/network' import { DocumentDomainInjection, getPath, parseUrlIntoHostProtocolDomainTldPort, removeDefaultPort } from '@packages/network-tools' @@ -287,7 +287,7 @@ export class ServerBase { }) .then((port) => { return Bluebird.all([ - httpsProxy.create(appData.path('proxy'), port, { + createHttpsProxy(appData.path('proxy'), port, { onRequest: this.callListeners.bind(this), onUpgrade: this.onSniUpgrade.bind(this), }), diff --git a/packages/server/test/performance/proxy_performance_spec.js b/packages/server/test/performance/proxy_performance_spec.js index 7bda324ea05..cd32ddcabf8 100644 --- a/packages/server/test/performance/proxy_performance_spec.js +++ b/packages/server/test/performance/proxy_performance_spec.js @@ -20,7 +20,7 @@ const { createRoutes } = require(`../../lib/routes`) process.env.CYPRESS_INTERNAL_ENV = 'development' -const CA = require('@packages/https-proxy').CA +const { CA } = require('@packages/https-proxy') const { setupFullConfigWithDefaults } = require('@packages/config') const { ServerBase } = require('../../lib/server-base') const { SocketE2E } = require('../../lib/socket-e2e') diff --git a/packages/server/test/unit/cloud/api/cloud_request_spec.ts b/packages/server/test/unit/cloud/api/cloud_request_spec.ts index 5e7a220d1af..03465bd94c7 100644 --- a/packages/server/test/unit/cloud/api/cloud_request_spec.ts +++ b/packages/server/test/unit/cloud/api/cloud_request_spec.ts @@ -16,7 +16,6 @@ import dedent from 'dedent' import { PassThrough } from 'stream' import fetch from 'cross-fetch' import nock from 'nock' -import { CA } from '@packages/https-proxy' import fs from 'fs-extra' chai.use(sinonChai) diff --git a/packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts b/packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts index 977ad0e4d91..71aa94bf6fe 100644 --- a/packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts +++ b/packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts @@ -26,7 +26,7 @@ app.get('/error', (req, res) => { res.status(404).json({ ok: false }) }) -let ca: ReturnType +let ca: CA interface DestroyableProxyOptions { keepRequests?: boolean diff --git a/scripts/unit/binary/upload-build-artifact-spec.js b/scripts/unit/binary/upload-build-artifact-spec.js index 43ca26b289b..29269dc9016 100644 --- a/scripts/unit/binary/upload-build-artifact-spec.js +++ b/scripts/unit/binary/upload-build-artifact-spec.js @@ -1,4 +1,4 @@ -const { sinon } = require('@packages/https-proxy/test/spec_helper') +const sinon = require('sinon') const { expect } = require('chai') const hasha = require('hasha') const fs = require('fs') diff --git a/system-tests/lib/system-tests.ts b/system-tests/lib/system-tests.ts index 217d1ad62bb..a630728e1b7 100644 --- a/system-tests/lib/system-tests.ts +++ b/system-tests/lib/system-tests.ts @@ -29,7 +29,7 @@ const human = require('human-interval') const morgan = require('morgan') const Bluebird = require('bluebird') const debug = require('debug')('cypress:system-tests') -const httpsProxy = require('@packages/https-proxy') +const { create: createHttpsServer } = require('@packages/https-proxy/test/helpers/https_server') const { allowDestroy } = require(`@packages/server/lib/util/server_destroy`) const settings = require(`@packages/server/lib/util/settings`) @@ -386,7 +386,7 @@ const startServer = function (obj) { const app = Express() - const srv = https ? httpsProxy.httpsServer(app) : new http.Server(app) + const srv = https ? createHttpsServer(app) : new http.Server(app) allowDestroy(srv) diff --git a/system-tests/test/visit_spec.js b/system-tests/test/visit_spec.js index 43cbcd1c2a4..f39d8ea3015 100644 --- a/system-tests/test/visit_spec.js +++ b/system-tests/test/visit_spec.js @@ -1,6 +1,6 @@ const _ = require('lodash') const Bluebird = require('bluebird') -const cert = require('@packages/https-proxy/test/helpers/certs') +const { options } = require('@packages/https-proxy/test/helpers/certs') const https = require('https') const useragent = require('express-useragent') const { allowDestroy } = require('@packages/network') @@ -11,7 +11,7 @@ const startTlsV1Server = (port) => { return Bluebird.fromCallback((cb) => { const opts = _.merge({ secureProtocol: 'TLSv1_server_method', - }, cert) + }, options) const serv = https.createServer(opts, (req, res) => { res.setHeader('content-type', 'text/html') diff --git a/tooling/v8-snapshot/src/setup/force-no-rewrite.ts b/tooling/v8-snapshot/src/setup/force-no-rewrite.ts index 630bfb360c4..786e969777e 100644 --- a/tooling/v8-snapshot/src/setup/force-no-rewrite.ts +++ b/tooling/v8-snapshot/src/setup/force-no-rewrite.ts @@ -5,7 +5,7 @@ * will be compared to the file's path and if they match, the given * file will be marked as force no rewritten. If we want to match the full path, we * should include the full relative path with respect to the project base (e.g. - * packages/https-proxy/lib/ca.js). For files where we want to match multiple hoisted + * packages/https-proxy/cjs/ca.js). For files where we want to match multiple hoisted * locations, we should specify the dependency starting with `* /` (e.g. * `* /node_modules/signal-exit/index.js`) */ @@ -27,7 +27,7 @@ export default [ // Has issues depending on the architecture due to how it handles errors '*/node_modules/@cypress/get-windows-proxy/src/registry.js', // results in recursive call to __get_fs2__ - 'packages/https-proxy/lib/ca.js', + 'packages/https-proxy/cjs/ca.js', // TODO: Figure out why these don't properly get flagged as norewrite: https://github.com/cypress-io/cypress/issues/23986 '*/node_modules/@cspotcode/source-map-support/source-map-support.js', 'packages/server/lib/modes/run.ts', diff --git a/yarn.lock b/yarn.lock index e9a506e78fa..f8de9acc6cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2700,11 +2700,6 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== -"@coolaj86/urequest@^1.3.6": - version "1.3.7" - resolved "https://registry.yarnpkg.com/@coolaj86/urequest/-/urequest-1.3.7.tgz#66a1d66378dd6534e9c8e68948bf09acf32bab77" - integrity sha512-PPrVYra9aWvZjSCKl/x1pJ9ZpXda1652oJrPBYy5rQumJJMkmTBN3ux+sK2xAUwVvv2wnewDlaQaHLxLwSHnIA== - "@cspotcode/source-map-support@^0.8.0": version "0.8.1" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" @@ -8803,6 +8798,11 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a" integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow== +"@types/semaphore@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@types/semaphore/-/semaphore-1.1.4.tgz#fe9a0deb4c680ec23e8ce6283b3bb94ab7744c64" + integrity sha512-W+KOVSGHKo5yoPXG69RFIKOdmvAcrAo2qnRrcDv80kIcxDnEUQ+c3IVKq0Jkp+BhhYfrbthPY9cXWFL0L9uzuw== + "@types/semver@7.5.8", "@types/semver@^7.5.0": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" @@ -14062,14 +14062,6 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -create-thenable@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/create-thenable/-/create-thenable-1.0.2.tgz#e2031720ccc9575d8cfa31f5c146e762a80c0534" - integrity sha1-4gMXIMzJV12M+jH1wUbnYqgMBTQ= - dependencies: - object.omit "~2.0.0" - unique-concat "~0.2.2" - cross-dirname@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/cross-dirname/-/cross-dirname-0.1.0.tgz#b899599f30a5389f59e78c150e19f957ad16a37c" @@ -17691,13 +17683,6 @@ for-in@^1.0.1, for-in@^1.0.2: resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= -for-own@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" - integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= - dependencies: - for-in "^1.0.1" - for-own@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" @@ -17780,13 +17765,6 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -formatio@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.1.1.tgz#5ed3ccd636551097383465d996199100e86161e9" - integrity sha1-XtPM1jZVEJc4NGXZlhmRAOhhYek= - dependencies: - samsam "~1.1" - formdata-polyfill@^4.0.10: version "4.0.10" resolved "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" @@ -22894,11 +22872,6 @@ loglevel@^1.6.0: resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08" integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== -lolex@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.3.2.tgz#7c3da62ffcb30f0f5a80a2566ca24e45d8a01f31" - integrity sha1-fD2mL/yzDw9agKJWbKJORdigHzE= - lolex@^2.4.2: version "2.7.5" resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.7.5.tgz#113001d56bfc7e02d56e36291cc5c413d1aa0733" @@ -24430,11 +24403,6 @@ napi-postinstall@^0.3.0: resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.0.tgz#888e51d1fb500e86dcf6ace1baccdbb377e654ce" integrity sha512-M7NqKyhODKV1gRLdkwE7pDsZP2/SC2a2vHkOYh9MCpKMbWVfyVfUw5MaH83Fv6XMjxr5jryUp3IDDL9rlxsTeA== -native-promise-only@~0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11" - integrity sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE= - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -25457,14 +25425,6 @@ object.map@^1.0.0: for-own "^1.0.0" make-iterator "^1.0.0" -object.omit@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" - integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo= - dependencies: - for-own "^0.1.4" - is-extendable "^0.1.1" - object.pick@^1.2.0, object.pick@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" @@ -28942,11 +28902,6 @@ safe-regex@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -samsam@1.1.2, samsam@~1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" - integrity sha1-vsEf3IOp/aBjQBIQ5AF2wwJNFWc= - samsam@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" @@ -29582,29 +29537,11 @@ simple-update-notifier@2.0.0: dependencies: semver "^7.5.3" -sinon-as-promised@4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/sinon-as-promised/-/sinon-as-promised-4.0.3.tgz#c0545b1685fd813588a4ed697012487ed11d151b" - integrity sha1-wFRbFoX9gTWIpO1pcBJIftEdFRs= - dependencies: - create-thenable "~1.0.0" - native-promise-only "~0.8.1" - sinon-chai@3.7.0, sinon-chai@^3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.7.0.tgz#cfb7dec1c50990ed18c153f1840721cf13139783" integrity sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g== -sinon@1.17.7: - version "1.17.7" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.7.tgz#4542a4f49ba0c45c05eb2e9dd9d203e2b8efe0bf" - integrity sha1-RUKk9JugxFwF6y6d2dID4rjv4L8= - dependencies: - formatio "1.1.1" - lolex "1.3.2" - samsam "1.1.2" - util ">=0.10.3 <1" - sinon@13.0.2, sinon@^13.0.1: version "13.0.2" resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a" @@ -30268,13 +30205,6 @@ sshpk@^1.18.0, sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" -ssl-root-cas@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/ssl-root-cas/-/ssl-root-cas-1.3.1.tgz#6b0566f7de4f0e6be99fbd93dbfbe5c7ab33b949" - integrity sha512-KR8J210Wfvjh+iNE9jcQEgbG0VG2713PHreItx6aNCPnkFO8XChz1cJ4iuCGeBj0+8wukLmgHgJqX+O5kRjPkQ== - dependencies: - "@coolaj86/urequest" "^1.3.6" - ssri@^10.0.0, ssri@^10.0.6: version "10.0.6" resolved "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz#a8aade2de60ba2bce8688e3fa349bad05c7dc1e5" @@ -31963,7 +31893,7 @@ tsx@4.20.5: optionalDependencies: fsevents "~2.3.3" -tsx@^4.20.5: +tsx@^4.20.5, tsx@^4.20.6: version "4.20.6" resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.20.6.tgz#8fb803fd9c1f70e8ccc93b5d7c5e03c3979ccb2e" integrity sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg== @@ -32371,11 +32301,6 @@ union-value@^1.0.0: is-extendable "^0.1.1" set-value "^2.0.1" -unique-concat@~0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/unique-concat/-/unique-concat-0.2.2.tgz#9210f9bdcaacc5e1e3929490d7c019df96f18712" - integrity sha1-khD5vcqsxeHjkpSQ18AZ35bxhxI= - unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" @@ -32749,7 +32674,7 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= -"util@>=0.10.3 <1", util@^0.12.0: +util@^0.12.0: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==