diff --git a/cli/schema/cypress.schema.json b/cli/schema/cypress.schema.json index a57568b346ae..7f8c5279204d 100644 --- a/cli/schema/cypress.schema.json +++ b/cli/schema/cypress.schema.json @@ -278,6 +278,76 @@ "type": "boolean", "default": false, "description": "Enables including elements within the shadow DOM when using querying commands (e.g. cy.get(), cy.find()). Can be set globally in cypress.json, per-suite or per-test in the test configuration object, or programmatically with Cypress.config()" + }, + "clientCertificates": { + "description": "Defines client certificates to use when sending requests to the specified URLs", + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "description": "Requests for URLs matching this minimatch pattern will use the supplied client certificate", + "type": "string" + }, + "ca": { + "description": "Path(s) to CA file(s) to validate certs against, relative to project root", + "type": "array", + "items": { + "type": "string" + } + }, + "certs": { + "type": "array", + "items": { + "anyOf": [ + { + "description": "PEM file specific properties", + "type": "object", + "properties": { + "cert": { + "description": "Path to the certificate, relative to project root", + "type": "string" + }, + "key": { + "description": "Path to the private key, relative to project root", + "type": "string" + }, + "passphrase": { + "description": "(Optional) File path to a UTF-8 text file containing the passphrase for the key, relative to project root", + "type": "string" + } + }, + "required": [ + "cert", + "key" + ] + }, + { + "description": "PFX file specific properties", + "type": "object", + "properties": { + "pfx": { + "description": "Path to the certificate container, relative to project root", + "type": "string" + }, + "passphrase": { + "description": "(Optional) File path to a UTF-8 text file containing the passphrase for the container, relative to project root", + "type": "string" + } + }, + "required": [ + "pfx" + ] + } + ] + } + } + }, + "required": [ + "url", + "certs" + ] + } } } } @@ -299,4 +369,4 @@ } } ] -} +} \ No newline at end of file diff --git a/packages/network/lib/agent.ts b/packages/network/lib/agent.ts index 861ce7c6af71..5229ff211582 100644 --- a/packages/network/lib/agent.ts +++ b/packages/network/lib/agent.ts @@ -7,11 +7,14 @@ import { getProxyForUrl } from 'proxy-from-env' import url from 'url' import { createRetryingSocket, getAddress } from './connect' import { lenientOptions } from './http-utils' +import { ClientCertificateStore } from './client-certificates' const debug = debugModule('cypress:network:agent') const CRLF = '\r\n' const statusCodeRe = /^HTTP\/1.[01] (\d*)/ +export const clientCertificateStore = new ClientCertificateStore() + type WithProxyOpts = RequestOptions & { proxy: string shouldRetry?: boolean @@ -49,8 +52,8 @@ interface CreateProxySockOpts { type CreateProxySockCb = ( (err: undefined, result: net.Socket, triggerRetry: (err: Error) => void) => void ) & ( - (err: Error) => void -) + (err: Error) => void + ) export const createProxySock = (opts: CreateProxySockOpts, cb: CreateProxySockCb) => { if (opts.proxy.protocol !== 'https:' && opts.proxy.protocol !== 'http:') { @@ -190,6 +193,8 @@ export class CombinedAgent { debug('got family %o', _.pick(options, 'family', 'href')) if (isHttps) { + _.assign(options, clientCertificateStore.getClientCertificateAgentOptionsForUrl(options.uri)) + return this.httpsAgent.addRequest(req, options) } diff --git a/packages/network/lib/client-certificates.ts b/packages/network/lib/client-certificates.ts new file mode 100644 index 000000000000..bee3ec337bdf --- /dev/null +++ b/packages/network/lib/client-certificates.ts @@ -0,0 +1,397 @@ +import { Url } from 'url' +import debugModule from 'debug' +import minimatch from 'minimatch' +import Forge from 'node-forge' +import fs from 'fs-extra' +import { clientCertificateStore } from './agent' +const { pki, asn1, pkcs12, util } = Forge + +const debug = debugModule('cypress:network:client-certificates') + +export class ParsedUrl { + constructor (url: string) { + if (url === '*' || url === 'https://*') { + this.host = '*' + this.path = undefined + this.port = undefined + } else { + let parsed = new URL(url) + + this.host = parsed.hostname + this.port = !parsed.port ? undefined : parseInt(parsed.port) + if (parsed.pathname.length === 0 || parsed.pathname === '/') { + this.path = undefined + } else if ( + parsed.pathname.length > 0 && + !parsed.pathname.endsWith('/') && + !parsed.pathname.endsWith('*') + ) { + this.path = `${parsed.pathname}/` + } else { + this.path = parsed.pathname + } + } + + this.hostMatcher = new minimatch.Minimatch(this.host) + this.pathMatcher = new minimatch.Minimatch(this.path ?? '') + } + + path: string | undefined; + host: string; + port: number | undefined; + hostMatcher: minimatch.IMinimatch; + pathMatcher: minimatch.IMinimatch; +} + +export class UrlMatcher { + static buildMatcherRule (url: string): ParsedUrl { + return new ParsedUrl(url) + } + + static matchUrl (hostname: string | undefined | null, path: string | undefined | null, port: number | undefined | null, rule: ParsedUrl | undefined): boolean { + if (!hostname || !rule) { + return false + } + + let ret = rule.hostMatcher.match(hostname) + + if (ret && rule.port) { + ret = rule.port === port + } + + if (ret && rule.path) { + ret = rule.pathMatcher?.match(path ?? '') + } + + return ret + } +} + +/** + * Defines the certificates that should be used for the specified URL + */ +export class UrlClientCertificates { + constructor (url: string) { + this.subjects = '' + this.url = url + this.pathnameLength = new URL(url).pathname.length + this.clientCertificates = new ClientCertificates() + } + clientCertificates: ClientCertificates; + url: string; + subjects: string; + pathnameLength: number; + matchRule: ParsedUrl | undefined; + + addSubject (subject: string) { + if (!this.subjects) { + this.subjects = subject + } else { + this.subjects = `${this.subjects} - ${subject}` + } + } +} + +/** + * Client certificates; this is in a data structure that is compatible with the NodeJS TLS API described + * at https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options + */ +export class ClientCertificates { + ca: Buffer[] = []; + cert: Buffer[] = []; + key: PemKey[] = []; + pfx: PfxCertificate[] = []; +} + +export class PemKey { + constructor (pem: Buffer, passphrase: string | undefined) { + this.pem = pem + this.passphrase = passphrase + } + + pem: Buffer; + passphrase: string | undefined; +} + +export class PfxCertificate { + constructor (buf: Buffer, passphrase: string | undefined) { + this.buf = buf + this.passphrase = passphrase + } + + buf: Buffer; + passphrase: string | undefined; +} + +export class ClientCertificateStore { + private _urlClientCertificates: UrlClientCertificates[] = []; + + addClientCertificatesForUrl (cert: UrlClientCertificates) { + debug( + 'ClientCertificateStore::addClientCertificatesForUrl: "%s"', + cert.url, + ) + + const existing = this._urlClientCertificates.find((x) => x.url === cert.url) + + if (existing) { + throw new Error(`ClientCertificateStore::addClientCertificatesForUrl: Url ${cert.url} already in store`) + } + + cert.matchRule = UrlMatcher.buildMatcherRule(cert.url) + this._urlClientCertificates.push(cert) + } + + getClientCertificateAgentOptionsForUrl (requestUrl: Url): ClientCertificates | null { + if ( + !this._urlClientCertificates || + this._urlClientCertificates.length === 0 + ) { + return null + } + + const port = !requestUrl.port ? undefined : parseInt(requestUrl.port) + const matchingCerts = this._urlClientCertificates.filter((cert) => { + return UrlMatcher.matchUrl(requestUrl.hostname, requestUrl.path, port, cert.matchRule) + }) + + switch (matchingCerts.length) { + case 0: + debug(`not using client certificate(s) for url '${requestUrl.href}'`) + + return null + case 1: + debug( + `using client certificate(s) '${matchingCerts[0].subjects}' for url '${requestUrl.href}'`, + ) + + return matchingCerts[0].clientCertificates + default: + matchingCerts.sort((a, b) => { + return b.pathnameLength - a.pathnameLength + }) + + debug( + `using client certificate(s) '${matchingCerts[0].subjects}' for url '${requestUrl.href}'`, + ) + + return matchingCerts[0].clientCertificates + } + } + + getCertCount (): Number { + return !this._urlClientCertificates ? 0 : this._urlClientCertificates.length + } + + clear (): void { + this._urlClientCertificates = [] + } +} + +/** + * Load and parse the client certificate configuration. The structure and content of this + * has already been validated; this function reads cert content from file and adds it to the + * network ClientCertificateStore + * @param config + */ +export function loadClientCertificateConfig (config) { + const { clientCertificates } = config + + let index = 0 + + try { + clientCertificateStore.clear() + + // The basic validation of the certificate configuration has already been done by this point + // within the 'isValidClientCertificatesSet' function within packages/server/lib/util/validation.js + if (clientCertificates) { + clientCertificates.forEach((item) => { + debug(`loading client cert at index ${index}`) + + const urlClientCertificates = new UrlClientCertificates(item.url) + + if (item.ca) { + item.ca.forEach((ca: string) => { + if (ca) { + debug(`loading CA cert from '${ca}'`) + const caRaw = loadBinaryFromFile(ca) + + try { + pki.certificateFromPem(caRaw) + } catch (error) { + throw new Error(`Cannot parse CA cert: ${error.message}`) + } + + urlClientCertificates.clientCertificates.ca.push(caRaw) + } + }) + } + + if (!item.certs || item.certs.length === 0) { + throw new Error('Either PEM or PFX must be supplied') + } + + item.certs.forEach((cert) => { + if (!cert || (!cert.cert && !cert.pfx)) { + throw new Error('Either PEM or PFX must be supplied') + } + + if (cert.cert) { + if (!cert.key) { + throw new Error(`No PEM key defined for cert: ${cert.cert}`) + } + + debug( + `loading PEM cert information from '${JSON.stringify(cert)}'`, + ) + + debug(`loading PEM cert from '${cert.cert}'`) + const pemRaw = loadBinaryFromFile(cert.cert) + let pemParsed = undefined + + try { + pemParsed = pki.certificateFromPem(pemRaw) + } catch (error) { + throw new Error(`Cannot parse PEM cert: ${error.message}`) + } + + urlClientCertificates.clientCertificates.cert.push(pemRaw) + + let passphrase: string | undefined = undefined + + if (cert.passphrase) { + debug(`loading PEM passphrase from '${cert.passphrase}'`) + passphrase = loadTextFromFile(cert.passphrase) + } + + debug(`loading PEM key from '${cert.key}'`) + const pemKeyRaw = loadBinaryFromFile(cert.key) + + try { + if (passphrase) { + if (!pki.decryptRsaPrivateKey(pemKeyRaw, passphrase)) { + throw new Error( + 'Cannot decrypt PEM key with supplied passphrase (check the passphrase file content and that it doesn\'t have unexpected whitespace at the end)', + ) + } + } else { + if (!pki.privateKeyFromPem(pemKeyRaw)) { + throw new Error('Cannot load PEM key') + } + } + } catch (error) { + throw new Error(`Cannot parse PEM key: ${error.message}`) + } + + urlClientCertificates.clientCertificates.key.push( + new PemKey(pemKeyRaw, passphrase), + ) + + const subject = extractSubjectFromPem(pemParsed) + + urlClientCertificates.addSubject(subject) + debug( + `loaded client PEM certificate: ${subject} for url: ${urlClientCertificates.url}`, + ) + } + + if (cert.pfx) { + debug( + `loading PFX cert information from '${JSON.stringify(cert)}'`, + ) + + let passphrase: string | undefined = undefined + + if (cert.passphrase) { + debug(`loading PFX passphrase from '${cert.passphrase}'`) + passphrase = loadTextFromFile(cert.passphrase) + } + + debug(`loading PFX cert from '${cert.pfx}'`) + const pfxRaw = loadBinaryFromFile(cert.pfx) + const pfxParsed = loadPfx(pfxRaw, passphrase) + + urlClientCertificates.clientCertificates.pfx.push( + new PfxCertificate(pfxRaw, passphrase), + ) + + const subject = extractSubjectFromPfx(pfxParsed) + + urlClientCertificates.addSubject(subject) + debug( + `loaded client PFX certificate: ${subject} for url: ${urlClientCertificates.url}`, + ) + } + }) + + clientCertificateStore.addClientCertificatesForUrl(urlClientCertificates) + index++ + }) + + debug( + `loaded client certificates for ${clientCertificateStore.getCertCount()} URL(s)`, + ) + } + } catch (e) { + debug( + `Failed to load client certificate for clientCertificates[${index}]: ${e.message} ${e.stack}`, + ) + + throw new Error( + `Failed to load client certificates for clientCertificates[${index}]: ${e.message}. For more debug details run Cypress with DEBUG=cypress:server:client-certificates*`, + ) + } +} + +function loadBinaryFromFile (filepath: string): Buffer { + debug(`loadCertificateFile: ${filepath}`) + + return fs.readFileSync(filepath) +} + +function loadTextFromFile (filepath: string): string { + debug(`loadPassphraseFile: ${filepath}`) + + return fs.readFileSync(filepath, 'utf8').toString() +} + +/** + * Extract subject from supplied pem instance + */ +function extractSubjectFromPem (pem): string { + try { + return pem.subject.attributes + .map((attr) => [attr.shortName, attr.value].join('=')) + .join(', ') + } catch (e) { + throw new Error(`Unable to extract subject from PEM file: ${e.message}`) + } +} + +/** + * Load PFX data from the supplied Buffer and passphrase + */ +function loadPfx (pfx: Buffer, passphrase: string | undefined) { + try { + const certDer = util.decode64(pfx.toString('base64')) + const certAsn1 = asn1.fromDer(certDer) + + return pkcs12.pkcs12FromAsn1(certAsn1, passphrase) + } catch (e) { + debug(`loadPfx fail: ${e.message} ${e.stackTrace}`) + throw new Error(`Unable to load PFX file: ${e.message}`) + } +} + +/** + * Extract subject from supplied pfx instance + */ +function extractSubjectFromPfx (pfx) { + try { + const certs = pfx.getBags({ bagType: pki.oids.certBag })[pki.oids.certBag].map((item) => item.cert) + + return certs[0].subject.attributes.map((attr) => [attr.shortName, attr.value].join('=')).join(', ') + } catch (e) { + throw new Error(`Unable to extract subject from PFX file: ${e.message}`) + } +} diff --git a/packages/network/lib/index.ts b/packages/network/lib/index.ts index 11a9a3936770..90d94efa41cb 100644 --- a/packages/network/lib/index.ts +++ b/packages/network/lib/index.ts @@ -4,6 +4,7 @@ import * as connect from './connect' import * as cors from './cors' import * as httpUtils from './http-utils' import * as uri from './uri' +import * as clientCertificates from './client-certificates' export { agent, @@ -12,6 +13,7 @@ export { cors, httpUtils, uri, + clientCertificates, } export { allowDestroy } from './allow-destroy' diff --git a/packages/network/package.json b/packages/network/package.json index e22fc2ac74d7..5eb24656fe6d 100644 --- a/packages/network/package.json +++ b/packages/network/package.json @@ -17,7 +17,9 @@ "bluebird": "3.5.3", "concat-stream": "1.6.2", "debug": "4.3.2", + "fs-extra": "8.1.0", "lodash": "4.17.21", + "node-forge": "0.10.0", "proxy-from-env": "1.0.0" }, "devDependencies": { diff --git a/packages/network/test/unit/agent_spec.ts b/packages/network/test/unit/agent_spec.ts index 91c067904822..eb3264434f2a 100644 --- a/packages/network/test/unit/agent_spec.ts +++ b/packages/network/test/unit/agent_spec.ts @@ -17,9 +17,13 @@ import { isResponseStatusCode200, regenerateRequestHead, CombinedAgent, + clientCertificateStore, } from '../../lib/agent' import { allowDestroy } from '../../lib/allow-destroy' import { AsyncServer, Servers } from '../support/servers' +import { UrlClientCertificates, ClientCertificates, PemKey } from '../../lib/client-certificates' +import Forge from 'node-forge' +const { pki } = Forge const expect = chai.expect @@ -29,6 +33,50 @@ const PROXY_PORT = 31000 const HTTP_PORT = 31080 const HTTPS_PORT = 31443 +function createCertAndKey (): [object, object] { + let keys = pki.rsa.generateKeyPair(2048) + let cert = pki.createCertificate() + + cert.publicKey = keys.publicKey + cert.serialNumber = '01' + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1) + + let attrs = [ + { + name: 'commonName', + value: 'example.org', + }, + { + name: 'countryName', + value: 'US', + }, + { + shortName: 'ST', + value: 'California', + }, + { + name: 'localityName', + value: 'San Fran', + }, + { + name: 'organizationName', + value: 'Test', + }, + { + shortName: 'OU', + value: 'Test', + }, + ] + + cert.setSubject(attrs) + cert.setIssuer(attrs) + cert.sign(keys.privateKey) + + return [cert, keys.privateKey] +} + describe('lib/agent', function () { beforeEach(function () { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' @@ -368,6 +416,104 @@ describe('lib/agent', function () { }) }) + context('CombinedAgent with client certificates', function () { + const proxyUrl = `https://localhost:${PROXY_PORT}` + + before(function () { + this.servers = new Servers() + + return this.servers.start(HTTP_PORT, HTTPS_PORT) + }) + + after(function () { + return this.servers.stop() + }) + + ;[ + { + name: 'should present a client certificate', + peresentClientCertificate: true, + }, + { + name: 'should present not a client certificate', + peresentClientCertificate: false, + }, + ].slice().map((testCase) => { + context(testCase.name, function () { + 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.HTTP_PROXY = process.env.HTTPS_PROXY = proxyUrl + process.env.NO_PROXY = '' + + this.agent = new CombinedAgent() + + this.request = request.defaults({ + proxy: null, + agent: this.agent, + }) + + let options: any = { + keepRequests: true, + https: this.servers.https, + auth: false, + } + + if (testCase.peresentClientCertificate) { + clientCertificateStore.clear() + const certAndKey = createCertAndKey() + const pemCert = pki.certificateToPem(certAndKey[0]) + + this.clientCert = pemCert + const testCerts = new UrlClientCertificates(`https://localhost`) + + testCerts.clientCertificates = new ClientCertificates() + testCerts.clientCertificates.cert.push(pemCert) + testCerts.clientCertificates.key.push(new PemKey(pki.privateKeyToPem(certAndKey[1]), undefined)) + clientCertificateStore.addClientCertificatesForUrl(testCerts) + } + + this.debugProxy = new DebuggingProxy(options) + + return this.debugProxy.start(PROXY_PORT) + }) + + afterEach(function () { + this.debugProxy.stop() + }) + + it('Client certificate presneted if appropriate', function () { + return this.request({ + url: `https://localhost:${HTTPS_PORT}/get`, + }).then((body) => { + expect(body).to.eq('It worked!') + if (this.debugProxy) { + expect(this.debugProxy.requests[0]).to.include({ + https: true, + url: `localhost:${HTTPS_PORT}`, + }) + } + + const socketKey = Object.keys(this.agent.httpsAgent.sockets).filter((key) => key.includes(`localhost:${HTTPS_PORT}`)) + + expect(socketKey.length).to.eq(1, 'There should only be a single localhost TLS Socket') + + // If a client cert has been assigned to a TLS connection, the key for the TLSSocket + // will include the public certificate + if (this.clientCert) { + expect(socketKey[0]).to.contain(this.clientCert, 'A client cert should be used for the TLS Socket') + } else { + expect(socketKey[0]).not.to.contain(this.clientCert, 'A client cert should not be used for the TLS Socket') + } + }) + }) + }) + }) + }) + context('.buildConnectReqHead', function () { it('builds the correct request', function () { const head = buildConnectReqHead('foo.bar', '1234', {}) diff --git a/packages/network/test/unit/client_certificates_spec.ts b/packages/network/test/unit/client_certificates_spec.ts new file mode 100644 index 000000000000..a7f89f9065a9 --- /dev/null +++ b/packages/network/test/unit/client_certificates_spec.ts @@ -0,0 +1,861 @@ +import { expect } from 'chai' +import { ParsedUrl, UrlMatcher, UrlClientCertificates, ClientCertificateStore, ClientCertificates, loadClientCertificateConfig } from '../../lib/client-certificates' +import { clientCertificateStore } from '../../lib/agent' +import urllib from 'url' +import fs from 'fs-extra' +import os from 'os' +import path from 'path' +import Forge from 'node-forge' +import { v4 as uuidv4 } from 'uuid' +const { pki, pkcs12, asn1 } = Forge + +function urlShouldMatch (url: string, matcher: string) { + let rule = UrlMatcher.buildMatcherRule(matcher) + let parsedUrl = new ParsedUrl(url) + + expect(UrlMatcher.matchUrl(parsedUrl.host, parsedUrl.path, parsedUrl.port, rule), `'${url}' should match '${matcher}' (rule: ${JSON.stringify(rule)})`).to.be.true +} + +function urlShouldNotMatch (url: string, matcher: string) { + let rule = UrlMatcher.buildMatcherRule(matcher) + let parsedUrl = new ParsedUrl(url) + + expect(UrlMatcher.matchUrl(parsedUrl.host, parsedUrl.path, parsedUrl.port, rule), `'${url}' should not match '${matcher}' (rule: ${JSON.stringify(rule)})`).to.be.false +} + +function checkParsed (parsed: ParsedUrl, host: string, path: string | undefined, port: number | undefined) { + expect(parsed.host, `'host ${parsed.host}' should be '${host}'`).to.eq(host) + expect(parsed.path, `'path ${parsed.path}' should be '${path}'`).to.eq(path) + expect(parsed.port, `'port ${parsed.port}' should be '${port}'`).to.eq(port) +} + +describe('lib/client-certificates', () => { + context('ParsedUrl', () => { + it('parses clean URLs', () => { + let parsed = new ParsedUrl('https://a.host.com') + + checkParsed(parsed, 'a.host.com', undefined, undefined) + + parsed = new ParsedUrl('https://a.host.com:1234') + expect(parsed.host).to.eq('a.host.com') + expect(parsed.port).to.eq(1234) + + parsed = new ParsedUrl('https://a.host.com/a/path/') + expect(parsed.host).to.eq('a.host.com') + expect(parsed.path).to.eq('/a/path/') + }) + + it('parses wildcard URLs', () => { + let parsed = new ParsedUrl('https://a.host.*') + + expect(parsed.host).to.eq('a.host.*') + + parsed = new ParsedUrl('https://*.host.com') + expect(parsed.host).to.eq('*.host.com') + + parsed = new ParsedUrl('https://a.host.com/a/path/*') + expect(parsed.host).to.eq('a.host.com') + expect(parsed.path).to.eq('/a/path/*') + + parsed = new ParsedUrl('https://a.host.com/*/path/') + expect(parsed.host).to.eq('a.host.com') + expect(parsed.path).to.eq('/*/path/') + + parsed = new ParsedUrl('*') + expect(parsed.host).to.eq('*') + expect(parsed.path).to.eq(undefined) + }) + }) + + context('ClientCertificateUrlMatcher', () => { + it('matches basic hostnames', () => { + let matcher = 'https://a.host.com' + + urlShouldMatch('https://a.host.com', matcher) + urlShouldMatch('https://a.host.com/a/path', matcher) + urlShouldNotMatch('https://a.host.co.uk', matcher) + }) + + it('matches wildcard hostnames', () => { + let matcher1 = 'https://a.host.*' + + urlShouldMatch('https://a.host.com', matcher1) + urlShouldMatch('https://a.host.com/a/path', matcher1) + urlShouldMatch('https://a.host.co.uk', matcher1) + urlShouldNotMatch('https://a.b.host.co.uk', matcher1) + + matcher1 = 'https://a.*.host.*' + urlShouldNotMatch('https://a.host.com', matcher1) + urlShouldNotMatch('https://z.a.host.com', matcher1) + urlShouldMatch('https://a.b.host.com', matcher1) + urlShouldMatch('https://a.b.c.host.com', matcher1) + urlShouldMatch('https://a.b.c.host.co.uk', matcher1) + + matcher1 = '*' + urlShouldMatch('https://a.host.com', matcher1) + urlShouldMatch('https://a.b.c.d.e.f.host.co.uk', matcher1) + }) + + it('matches basic paths', () => { + let matcher = 'https://a.path.com/a' + + urlShouldMatch('https://a.path.com/a', matcher) + urlShouldNotMatch('https://a.path.com', matcher) + urlShouldNotMatch('https://a.path.com/a/b', matcher) + }) + + it('matches wildcard paths', () => { + let matcher = 'https://a.path2.com/**' + + urlShouldMatch('https://a.path2.com/a', matcher) + urlShouldMatch('https://a.path2.com/a/b', matcher) + }) + }) + + context('UrlClientCertificates', () => { + it('constructs, populates default properties', () => { + let url = 'http://a.host.com/home' + let certs = new UrlClientCertificates(url) + + expect(certs.url).to.eq(url) + expect(certs.pathnameLength).to.eq(5) + expect(certs) + }) + }) + + context('ClientCertificateStore', () => { + it('adds and retrieves certs for urls', () => { + const url1 = urllib.parse('https://host.com') + const url2 = urllib.parse('https://company.com') + const store = new ClientCertificateStore() + + expect(store.getCertCount()).to.eq(0) + + let options = store.getClientCertificateAgentOptionsForUrl(url1) + + expect(options).to.eq(null) + + const certs1 = new UrlClientCertificates(url1.href) + + certs1.clientCertificates = new ClientCertificates() + certs1.clientCertificates.ca.push(Buffer.from([1, 2, 3, 4])) + + const certs2 = new UrlClientCertificates(url2.href) + + certs2.clientCertificates = new ClientCertificates() + certs2.clientCertificates.ca.push(Buffer.from([4, 3, 2, 1])) + + store.addClientCertificatesForUrl(certs1) + expect(store.getCertCount()).to.eq(1) + + store.addClientCertificatesForUrl(certs2) + expect(store.getCertCount()).to.eq(2) + + const act = () => { + store.addClientCertificatesForUrl(certs2) + } + + expect(act).to.throw('ClientCertificateStore::addClientCertificatesForUrl: Url https://company.com/ already in store') + + const options1 = store.getClientCertificateAgentOptionsForUrl(url1) + const options2 = store.getClientCertificateAgentOptionsForUrl(url2) + + expect(options1.ca).to.eq(certs1.clientCertificates.ca) + expect(options2.ca).to.eq(certs2.clientCertificates.ca) + }) + }) +}) + +// The following testing covers the areas: +// PEM: +// Valid crt/key, no passphrase +// Valid crt/key, passphrase +// Valid crt/key, relative pathing +// Valid crt/key, invalid (and not there) passphrase +// Multiple crt/key +// Invalid crt +// Invalid key +// Invalid ca +// Missing crt +// Missing key +// Missing ca +// PFX: +// Valid pfx and passphrase +// Valid pfx, passphrase +// Valid pfx, INVALID passphrase (invalid and not there) +// Invalid pfx +// Missing pfx +// Missing passphrase +// +// Neither PEM nor PFX supplied + +function createCertAndKey (): [object, object] { + let keys = pki.rsa.generateKeyPair(2048) + let cert = pki.createCertificate() + + cert.publicKey = keys.publicKey + cert.serialNumber = '01' + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1) + + let attrs = [ + { + name: 'commonName', + value: 'example.org', + }, + { + name: 'countryName', + value: 'US', + }, + { + shortName: 'ST', + value: 'California', + }, + { + name: 'localityName', + value: 'San Fran', + }, + { + name: 'organizationName', + value: 'Test', + }, + { + shortName: 'OU', + value: 'Test', + }, + ] + + cert.setSubject(attrs) + cert.setIssuer(attrs) + cert.sign(keys.privateKey) + + return [cert, keys.privateKey] +} + +function createPemFiles ( + certFilepath: string, + keyFilepath: string, + passphraseFilepath: string | undefined, + passphrase: string | undefined, +) { + const certInfo = createCertAndKey() + + fs.writeFileSync(certFilepath, pki.certificateToPem(certInfo[0])) + const key = passphrase + ? pki.encryptRsaPrivateKey(certInfo[1], passphrase) + : pki.privateKeyToPem(certInfo[1]) + + fs.writeFileSync(keyFilepath, key) + + if (passphraseFilepath) { + fs.writeFileSync(passphraseFilepath, passphrase) + } +} + +function createPfxFiles ( + certFilepath: string, + passphraseFilepath: string | undefined, + passphrase: string | undefined, +) { + const certInfo = createCertAndKey() + + let p12Asn1 = pkcs12.toPkcs12Asn1(certInfo[1], [certInfo[0]], passphrase) + + fs.writeFileSync(certFilepath, asn1.toDer(p12Asn1).getBytes(), { encoding: 'binary' }) + + if (passphraseFilepath) { + fs.writeFileSync(passphraseFilepath, passphrase) + } +} + +function createCaFile (filepath: string) { + const certInfo = createCertAndKey() + + fs.writeFileSync(filepath, pki.certificateToPem(certInfo[0])) +} + +function createUniqueUrl (): string { + return `http://${uuidv4()}` +} + +function createSinglePemConfig ( + url, + caFilepath, + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, +) { + return { + projectRoot: __dirname, + clientCertificates: [ + { + url, + ca: [caFilepath], + certs: [ + { + cert: pemFilepath, + key: pemKeyFilepath, + passphrase: pemPassphraseFilepath, + }, + ], + }, + ], + } +} + +function createSinglePfxConfig ( + url, + caFilepath, + pfxFilepath, + pfxPassphraseFilepath, +) { + return { + projectRoot: __dirname, + clientCertificates: [ + { + url, + ca: [caFilepath], + certs: [ + { + pfx: pfxFilepath, + passphrase: pfxPassphraseFilepath, + }, + ], + }, + ], + } +} + +const tempDirName = 'server-pki-tests' +const caFilename = 'testca.crt' +const pemFilename = 'testpem.crt' +const pemKeyFilename = 'testpem.key' +const pemPassphraseFilename = 'testpem.pass' +const pfxFilename = 'testpfx.p12' +const pfxPassphraseFilename = 'testpfx.pass' + +const tempDirPath = path.join(os.tmpdir(), tempDirName) +const caFilepath = path.join(tempDirPath, caFilename) +const pemFilepath = path.join(tempDirPath, pemFilename) +const pemKeyFilepath = path.join(tempDirPath, pemKeyFilename) +const pemPassphraseFilepath = path.join(tempDirPath, pemPassphraseFilename) +const pfxFilepath = path.join(tempDirPath, pfxFilename) +const pfxPassphraseFilepath = path.join(tempDirPath, pfxPassphraseFilename) + +describe('lib/client-certificates', () => { + before(() => { + if (!fs.existsSync(tempDirPath)) { + fs.mkdirSync(tempDirPath) + } + }) + + after(() => { + fs.rmdirSync(tempDirPath, { recursive: true }) + }) + + context('loads cert files', () => { + it('loads valid single PEM (no passphrase) and CA via absolute pathing', () => { + createPemFiles(pemFilepath, pemKeyFilepath, undefined, undefined) + createCaFile(caFilepath) + + const url = createUniqueUrl() + const config = createSinglePemConfig( + url, + caFilepath, + pemFilepath, + pemKeyFilepath, + undefined, + ) + const pemFileData = fs.readFileSync(pemFilepath) + const keyFileData = fs.readFileSync(pemKeyFilepath) + const caFileData = fs.readFileSync(caFilepath) + + loadClientCertificateConfig(config) + const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( + urllib.parse(url), + ) + + expect(options).not.to.be.null + expect(options.ca.length).to.eq(1) + expect(options.ca[0]).to.deep.equal(caFileData) + expect(options.pfx).to.be.empty + expect(options.cert.length).to.eq(1) + expect(options.cert[0]).to.deep.equal(pemFileData) + expect(options.key.length).to.eq(1) + expect(options.key[0].passphrase).to.be.undefined + expect(options.key[0].pem).to.deep.equal(keyFileData) + }) + + it('loads valid multiple PEMs (no passphrase) and CAs', () => { + const pemFilepath1 = path.join(tempDirPath, 'testpem1.crt') + const keyFilepath1 = path.join(tempDirPath, 'testpem1.key') + const caFilepath1 = path.join(tempDirPath, 'testca1.crt') + const pemFilepath2 = path.join(tempDirPath, 'testpem2.crt') + const keyFilepath2 = path.join(tempDirPath, 'testpem2.key') + const caFilepath2 = path.join(tempDirPath, 'testca2.crt') + const pemFilepath3 = path.join(tempDirPath, 'testpem3.crt') + const keyFilepath3 = path.join(tempDirPath, 'testpem3.key') + const caFilepath3 = path.join(tempDirPath, 'testca3.crt') + + createPemFiles(pemFilepath1, keyFilepath1, undefined, undefined) + createPemFiles(pemFilepath2, keyFilepath2, undefined, undefined) + createPemFiles(pemFilepath3, keyFilepath3, undefined, undefined) + createCaFile(caFilepath1) + createCaFile(caFilepath2) + createCaFile(caFilepath3) + + const url = createUniqueUrl() + const config = { + projectRoot: __dirname, + clientCertificates: [ + { + url, + ca: [caFilepath1, caFilepath2, caFilepath3], + certs: [ + { + cert: pemFilepath1, + key: keyFilepath1, + }, + { + cert: pemFilepath2, + key: keyFilepath2, + }, + { + cert: pemFilepath3, + key: keyFilepath3, + }, + ], + }, + ], + } + + const pemFileData1 = fs.readFileSync(pemFilepath1) + const keyFileData1 = fs.readFileSync(keyFilepath1) + const caFileData1 = fs.readFileSync(caFilepath1) + const pemFileData2 = fs.readFileSync(pemFilepath2) + const keyFileData2 = fs.readFileSync(keyFilepath2) + const caFileData2 = fs.readFileSync(caFilepath2) + const pemFileData3 = fs.readFileSync(pemFilepath3) + const keyFileData3 = fs.readFileSync(keyFilepath3) + const caFileData3 = fs.readFileSync(caFilepath3) + + loadClientCertificateConfig(config) + const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( + urllib.parse(url), + ) + + expect(options).not.to.be.null + expect(options.ca.length).to.eq(3) + expect(options.ca[0]).to.deep.equal(caFileData1) + expect(options.ca[1]).to.deep.equal(caFileData2) + expect(options.ca[2]).to.deep.equal(caFileData3) + expect(options.pfx).to.be.empty + expect(options.cert.length).to.eq(3) + expect(options.cert[0]).to.deep.equal(pemFileData1) + expect(options.cert[1]).to.deep.equal(pemFileData2) + expect(options.cert[2]).to.deep.equal(pemFileData3) + expect(options.key.length).to.eq(3) + expect(options.key[0].passphrase).to.be.undefined + expect(options.key[0].pem).to.deep.equal(keyFileData1) + expect(options.key[1].pem).to.deep.equal(keyFileData2) + expect(options.key[2].pem).to.deep.equal(keyFileData3) + }) + + it('loads valid single PEM (with passphrase)', () => { + const passphrase = 'a_phrase' + + createPemFiles( + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + passphrase, + ) + + const url = createUniqueUrl() + const config = createSinglePemConfig( + url, + undefined, + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + ) + const pemFileData = fs.readFileSync(pemFilepath) + const keyFileData = fs.readFileSync(pemKeyFilepath) + + loadClientCertificateConfig(config) + const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( + urllib.parse(url), + ) + + expect(options).not.to.be.null + expect(options.ca.length).to.eq(0) + expect(options.pfx).to.be.empty + expect(options.cert.length).to.eq(1) + expect(options.cert[0]).to.deep.equal(pemFileData) + expect(options.key.length).to.eq(1) + expect(options.key[0].passphrase).to.equal(passphrase) + expect(options.key[0].pem).to.deep.equal(keyFileData) + }) + + it('loads valid single PEM and CA via relative pathing', () => { + createPemFiles(pemFilepath, pemKeyFilepath, undefined, undefined) + createCaFile(caFilepath) + + const relativeCaFilepath = path.relative(__dirname, caFilepath) + const relativePemFilepath = path.relative(__dirname, pemFilepath) + const relativeKeyFilepath = path.relative(__dirname, pemKeyFilepath) + + const url = createUniqueUrl() + const config = createSinglePemConfig( + url, + relativeCaFilepath, + relativePemFilepath, + relativeKeyFilepath, + undefined, + ) + + loadClientCertificateConfig(config) + const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( + urllib.parse(url), + ) + + expect(options).not.to.be.null + expect(options.ca.length).to.eq(1) + expect(options.pfx).to.be.empty + expect(options.cert.length).to.eq(1) + expect(options.key.length).to.eq(1) + expect(options.key[0].passphrase).to.be.undefined + }) + + it('detects invalid PEM key passphrase', () => { + const passphrase = 'a_phrase' + + createPemFiles( + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + passphrase, + ) + + fs.writeFileSync(pemPassphraseFilepath, 'not-the-passphrase') + + const url = createUniqueUrl() + const config = createSinglePemConfig( + url, + undefined, + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw( + 'Cannot decrypt PEM key with supplied passphrase (check the passphrase file content and that it doesn\'t have unexpected whitespace at the end)', + ) + }) + + it('detects invalid PEM key file (no passphrase)', () => { + createPemFiles(pemFilepath, pemKeyFilepath, undefined, undefined) + fs.writeFileSync(pemKeyFilepath, 'not-a-key') + + const url = createUniqueUrl() + const config = createSinglePemConfig( + url, + undefined, + pemFilepath, + pemKeyFilepath, + undefined, + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Invalid PEM formatted message') + }) + + it('detects invalid PEM key file (with passphrase)', () => { + const passphrase = 'a_phrase' + + createPemFiles( + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + passphrase, + ) + + fs.writeFileSync(pemKeyFilepath, 'not-a-key') + + const url = createUniqueUrl() + const config = createSinglePemConfig( + url, + undefined, + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Invalid PEM formatted message') + }) + + it('detects invalid PEM cert file', () => { + fs.writeFileSync(pemFilepath, 'not-a-cert') + + const url = createUniqueUrl() + const config = createSinglePemConfig( + url, + undefined, + pemFilepath, + pemKeyFilepath, + undefined, + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Cannot parse PEM cert') + }) + + it('detects invalid CA file', () => { + fs.writeFileSync(caFilepath, 'not-a-cert') + + const url = createUniqueUrl() + const config = createSinglePemConfig( + url, + caFilepath, + pemFilepath, + pemKeyFilepath, + undefined, + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Cannot parse CA cert') + }) + + it('detects missing PEM cert file', () => { + const url = createUniqueUrl() + const passphrase = 'a_phrase' + + createPemFiles( + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + passphrase, + ) + + const config = createSinglePemConfig( + url, + undefined, + 'not-a-path', + pemKeyFilepath, + pemPassphraseFilepath, + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('no such file or directory') + }) + + it('detects missing PEM key file', () => { + const url = createUniqueUrl() + const passphrase = 'a_phrase' + + createPemFiles( + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + passphrase, + ) + + const config = createSinglePemConfig( + url, + undefined, + pemFilepath, + 'not-a-path', + pemPassphraseFilepath, + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('no such file or directory') + }) + + it('detects missing PEM passphrase file', () => { + const url = createUniqueUrl() + const passphrase = 'a_phrase' + + createPemFiles( + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + passphrase, + ) + + const config = createSinglePemConfig( + url, + undefined, + pemFilepath, + pemKeyFilepath, + 'not-a-path', + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('no such file or directory') + }) + + it('detects missing CA file', () => { + const url = createUniqueUrl() + const passphrase = 'a_phrase' + + createPemFiles( + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + passphrase, + ) + + createCaFile(caFilepath) + const config = createSinglePemConfig( + url, + 'not-a-path', + pemFilepath, + pemKeyFilepath, + pemPassphraseFilepath, + ) + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('no such file or directory') + }) + + it('loads valid single PFX', () => { + const passphrase = 'a_passphrase' + + createPfxFiles(pfxFilepath, pfxPassphraseFilepath, passphrase) + + const url = createUniqueUrl() + const config = createSinglePfxConfig( + url, + undefined, + pfxFilepath, + pfxPassphraseFilepath, + ) + const pfxFileData = fs.readFileSync(pfxFilepath) + + loadClientCertificateConfig(config) + + const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( + urllib.parse(url), + ) + + expect(options).not.to.be.null + expect(options.cert).to.be.empty + expect(options.pfx.length).to.eq(1) + expect(options.pfx[0].buf).to.deep.equal(pfxFileData) + expect(options.pfx[0].passphrase).to.equal(passphrase) + }) + + it('detects invalid PFX passphrase', () => { + const passphrase = 'a_passphrase' + + createPfxFiles(pfxFilepath, undefined, passphrase) + fs.writeFileSync(pfxPassphraseFilepath, 'not-a-passphrase') + + const config = createSinglePfxConfig( + createUniqueUrl(), + undefined, + pfxFilepath, + pfxPassphraseFilepath, + ) + + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Invalid password?') + }) + + it('detects missing PFX passphrase file', () => { + const passphrase = 'a_passphrase' + + createPfxFiles(pfxFilepath, undefined, passphrase) + + const config = createSinglePfxConfig( + createUniqueUrl(), + undefined, + pfxFilepath, + pfxPassphraseFilepath, + ) + + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Invalid password?') + }) + + it('detects invalid PFX file', () => { + fs.writeFileSync(pfxFilepath, 'not-a-pfx') + + const config = createSinglePfxConfig( + createUniqueUrl(), + undefined, + pfxFilepath, + pfxPassphraseFilepath, + ) + + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Unable to load PFX file: Too few bytes to read ASN.1 value') + }) + + it('detects missing PFX file', () => { + const config = createSinglePfxConfig( + createUniqueUrl(), + undefined, + pfxFilepath, + pfxPassphraseFilepath, + ) + + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Unable to load PFX file: Too few bytes to read ASN.1 value') + }) + + it('detects neither PEM nor PFX supplied', () => { + const config = { + projectRoot: __dirname, + clientCertificates: [ + { + url: createUniqueUrl(), + ca: [], + certs: [ + ], + }, + ], + } + + const act = () => { + loadClientCertificateConfig(config) + } + + expect(act).to.throw('Either PEM or PFX must be supplied') + }) + }) +}) diff --git a/packages/server/lib/config_options.ts b/packages/server/lib/config_options.ts index dded69774f92..9205a4e51a91 100644 --- a/packages/server/lib/config_options.ts +++ b/packages/server/lib/config_options.ts @@ -36,6 +36,10 @@ export const options = [ name: 'clientRoute', defaultValue: '/__/', isInternal: true, + }, { + name: 'clientCertificates', + defaultValue: [], + validation: v.isValidClientCertificatesSet, }, { name: 'component', // runner-ct overrides diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index a721e0e8a68b..60fd393f5634 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -12,7 +12,7 @@ import url from 'url' import la from 'lazy-ass' import httpsProxy from '@packages/https-proxy' import { netStubbingState, NetStubbingState } from '@packages/net-stubbing' -import { agent, cors, httpUtils, uri } from '@packages/network' +import { agent, clientCertificates, cors, httpUtils, uri } from '@packages/network' import { NetworkProxy, BrowserPreRequest } from '@packages/proxy' import { SocketCt } from '@packages/server-ct' import errors from './errors' @@ -197,6 +197,8 @@ export abstract class ServerBase { this._socket = new SocketCtor(config) as TSocket + clientCertificates.loadClientCertificateConfig(config) + const getRemoteState = () => { return this._getRemoteState() } diff --git a/packages/server/lib/util/validation.js b/packages/server/lib/util/validation.js index 334f92f5809b..51e11148019d 100644 --- a/packages/server/lib/util/validation.js +++ b/packages/server/lib/util/validation.js @@ -3,6 +3,7 @@ const debug = require('debug')('cypress:server:validation') const is = require('check-more-types') const { commaListsOr } = require('common-tags') const configOptions = require('../config_options') +const path = require('path') // validation functions take a key and a value and should: // - return true if it passes validation @@ -130,9 +131,9 @@ const isValidFirefoxGcInterval = (key, value) => { } if (isIntervalValue(value) - || (_.isEqual(_.keys(value), ['runMode', 'openMode']) - && isIntervalValue(value.runMode) - && isIntervalValue(value.openMode))) { + || (_.isEqual(_.keys(value), ['runMode', 'openMode']) + && isIntervalValue(value.runMode) + && isIntervalValue(value.openMode))) { return true } @@ -185,7 +186,99 @@ const isOneOf = (...values) => { } } +/** + * Validates whether the supplied set of cert information is valid + * @returns {string|true} Returns `true` if the information set is valid. Returns an error message if it is not. + */ +const isValidClientCertificatesSet = (key, certsForUrls) => { + debug('clientCerts: %o', certsForUrls) + + if (!Array.isArray(certsForUrls)) { + return errMsg(`clientCertificates.certs`, certsForUrls, 'an array of certs for URLs') + } + + let urls = [] + + for (let i = 0; i < certsForUrls.length; i++) { + debug(`Processing clientCertificates: ${i}`) + let certsForUrl = certsForUrls[i] + + if (!certsForUrl.url) { + return errMsg(`clientCertificates[${i}].url`, certsForUrl.url, 'a URL matcher') + } + + if (certsForUrl.url !== '*') { + try { + let parsed = new URL(certsForUrl.url) + + if (parsed.protocol !== 'https:') { + return errMsg(`clientCertificates[${i}].url`, certsForUrl.url, 'an https protocol') + } + } catch (e) { + return errMsg(`clientCertificates[${i}].url`, certsForUrl.url, 'a valid URL') + } + } + + if (urls.includes(certsForUrl.url)) { + return `clientCertificates has duplicate client certificate URL: ${certsForUrl.url}` + } + + urls.push(certsForUrl.url) + + if (certsForUrl.ca && !Array.isArray(certsForUrl.ca)) { + return errMsg(`clientCertificates[${i}].ca`, certsForUrl.ca, 'an array of CA filepaths') + } + + if (!Array.isArray(certsForUrl.certs)) { + return errMsg(`clientCertificates[${i}].certs`, certsForUrl.certs, 'an array of certs') + } + + for (let j = 0; j < certsForUrl.certs.length; j++) { + let certInfo = certsForUrl.certs[j] + + // Only one of PEM or PFX cert allowed + if (certInfo.cert && certInfo.pfx) { + return `\`clientCertificates[${i}].certs[${j}]\` has both PEM and PFX defined` + } + + if (!certInfo.cert && !certInfo.pfx) { + return `\`clientCertificates[${i}].certs[${j}]\` must have either PEM or PFX defined` + } + + if (certInfo.pfx) { + if (path.isAbsolute(certInfo.pfx)) { + return errMsg(`clientCertificates[${i}].certs[${j}].pfx`, certInfo.pfx, 'a relative filepath') + } + } + + if (certInfo.cert) { + if (path.isAbsolute(certInfo.cert)) { + return errMsg(`clientCertificates[${i}].certs[${j}].cert`, certInfo.cert, 'a relative filepath') + } + + if (!certInfo.key) { + return errMsg(`clientCertificates[${i}].certs[${j}].key`, certInfo.key, 'a key filepath') + } + + if (path.isAbsolute(certInfo.key)) { + return errMsg(`clientCertificates[${i}].certs[${j}].key`, certInfo.key, 'a relative filepath') + } + } + } + + for (let k = 0; k < certsForUrl.ca.length; k++) { + if (path.isAbsolute(certsForUrl.ca[k])) { + return errMsg(`clientCertificates[${k}].ca[${k}]`, certsForUrl.ca[k], 'a relative filepath') + } + } + } + + return true +} + module.exports = { + isValidClientCertificatesSet, + isValidBrowser, isValidBrowserList, diff --git a/packages/server/test/unit/config_spec.js b/packages/server/test/unit/config_spec.js index aab1239f8dcb..51d7baa382f9 100644 --- a/packages/server/test/unit/config_spec.js +++ b/packages/server/test/unit/config_spec.js @@ -884,6 +884,137 @@ describe('lib/config', () => { return this.expectValidationFails('a positive number or null or an object') }) }) + + function pemCertificate () { + return { + clientCertificates: [ + { + url: 'https://somewhere.com/*', + ca: ['certs/ca.crt'], + certs: [ + { + cert: 'certs/cert.crt', + key: 'certs/cert.key', + passphrase: 'certs/cert.key.pass', + }, + ], + }, + ], + } + } + + function pfxCertificate () { + return { + clientCertificates: [ + { + url: 'https://somewhere.com/*', + ca: ['certs/ca.crt'], + certs: [ + { + pfx: 'certs/cert.pfx', + passphrase: 'certs/cerpfx.pass', + }, + ], + }, + ], + } + } + + context('clientCertificates', () => { + it('accepts valid PEM config', function () { + this.setup(pemCertificate()) + + return this.expectValidationPasses() + }) + + it('accepts valid PFX config', function () { + this.setup(pfxCertificate()) + + return this.expectValidationPasses() + }) + + it('detects invalid config with no url', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].url = null + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].url` to be a URL matcher') + }) + + it('detects invalid config with no certs', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].certs = null + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].certs` to be an array of certs') + }) + + it('detects invalid config with no cert', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].certs[0].cert = null + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].certs[0]` must have either PEM or PFX defined') + }) + + it('detects invalid config with PEM and PFX certs', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].certs[0].pfx = 'a_file' + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].certs[0]` has both PEM and PFX defined') + }) + + it('detects invalid PEM config with no key', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].certs[0].key = null + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].certs[0].key` to be a key filepath') + }) + + it('detects PEM cert absolute path', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].certs[0].cert = '/home/files/a_file' + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].certs[0].cert` to be a relative filepath') + }) + + it('detects PEM key absolute path', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].certs[0].key = '/home/files/a_file' + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].certs[0].key` to be a relative filepath') + }) + + it('detects PFX absolute path', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].certs[0].cert = undefined + cfg.clientCertificates[0].certs[0].pfx = '/home/files/a_file' + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].certs[0].pfx` to be a relative filepath') + }) + + it('detects CA absolute path', function () { + let cfg = pemCertificate() + + cfg.clientCertificates[0].ca[0] = '/home/files/a_file' + this.setup(cfg) + + return this.expectValidationFails('`clientCertificates[0].ca[0]` to be a relative filepath') + }) + }) }) }) @@ -1321,6 +1452,7 @@ describe('lib/config', () => { blockHosts: { value: null, from: 'default' }, browsers: { value: [], from: 'default' }, chromeWebSecurity: { value: true, from: 'default' }, + clientCertificates: { value: [], from: 'default' }, component: { from: 'default', value: {} }, componentFolder: { value: 'cypress/component', from: 'default' }, defaultCommandTimeout: { value: 4000, from: 'default' }, @@ -1406,6 +1538,7 @@ describe('lib/config', () => { browsers: { value: [], from: 'default' }, chromeWebSecurity: { value: true, from: 'default' }, component: { from: 'default', value: {} }, + clientCertificates: { value: [], from: 'default' }, componentFolder: { value: 'cypress/component', from: 'default' }, defaultCommandTimeout: { value: 4000, from: 'default' }, downloadsFolder: { value: 'cypress/downloads', from: 'default' }, @@ -1571,7 +1704,7 @@ describe('lib/config', () => { context('_.defaultsDeep', () => { it('merges arrays', () => { - // sanity checks to confirm how Lodash merges arrays in defaultsDeep + // sanity checks to confirm how Lodash merges arrays in defaultsDeep const diffs = { list: [1], }