Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: client certificate selector option during e2e tests #30203

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 13.15.1

_Released 9/26/2024 (PENDING)_

**Features:**

- Client certificate selector option during e2e tests. Addresses [#27302](https://github.com/cypress-io/cypress/issues/27302).

## 13.15.0

_Released 9/25/2024_
Expand Down
13 changes: 13 additions & 0 deletions cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2141,6 +2141,11 @@ declare namespace Cypress {
*/
task<S = unknown>(event: string, arg?: any, options?: Partial<Loggable & Timeoutable>): Chainable<S>

/**
* Choose a cert in the system's cert store based on group.
*/
chooseCert(group: string): void

/**
* Enables you to work with the subject yielded from the previous command.
*
Expand Down Expand Up @@ -2815,6 +2820,10 @@ declare namespace Cypress {
}

interface PEMCert {
/**
* Group for filtering certificates (Optional)
*/
group?: string
/**
* Path to the certificate file, relative to project root.
*/
Expand All @@ -2830,6 +2839,10 @@ declare namespace Cypress {
}

interface PFXCert {
/**
* Group for filtering certificates (Optional)
*/
group?: string
/**
* Path to the certificate container, relative to project root.
*/
Expand Down
47 changes: 47 additions & 0 deletions packages/driver/src/cy/commands/chooseCert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import _ from 'lodash'

import $errUtils from '../../cypress/error_utils'
import type { Log } from '../../cypress/log'
import { runPrivilegedCommand } from '../../util/privileged_channel'

interface InternalChooseCertOptions extends Partial<Cypress.Loggable> {
_log?: Log
}

export default (Commands, Cypress, cy) => {
Commands.addAll({
chooseCert (group: string | null, userOptions: Partial<Cypress.Loggable>) {
const options: InternalChooseCertOptions = _.defaults({}, userOptions, {
log: true,
})

let consoleOutput = {}

let message = `choosing cert group: ${group}`

options._log = Cypress.log({
hidden: !options.log,
message,
consoleProps () {
return consoleOutput
},
})

if (group !== null && !_.isString(group)) {
$errUtils.throwErrByPath('chooseCert.invalid_argument', {
onFail: options._log,
args: { group: group || 'null' },
})
}

return runPrivilegedCommand({
commandName: 'chooseCert',
cy,
Cypress: (Cypress as unknown) as InternalCypress.Cypress,
options: {
group,
},
})
},
})
}
3 changes: 3 additions & 0 deletions packages/driver/src/cy/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import * as Aliasing from './aliasing'

import * as Asserting from './asserting'

import * as ChooseCert from './chooseCert'

import * as Clock from './clock'

import * as Commands from './commands'
Expand Down Expand Up @@ -57,6 +59,7 @@ export const allCommands = {
Agents,
Aliasing,
Asserting,
ChooseCert,
Clock,
Commands,
Connectors,
Expand Down
6 changes: 6 additions & 0 deletions packages/driver/src/cypress/error_messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ export default {
},
},

chooseCert: {
invalid_argument: {
message: `${cmd('chooseCert')} must be passed with a valid string (used in certificate groups) or null. You passed: \`{{group}}\`.`,
},
},

clear: {
invalid_element: {
message: stripIndent`\
Expand Down
8 changes: 4 additions & 4 deletions packages/network/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@ type FamilyCache = {
[host: string]: 4 | 6
}

export function buildConnectReqHead (hostname: string, port: string, proxy: url.Url) {
export function buildConnectReqHead (hostname: string, port: string, proxyAuth?: string | null) {
const connectReq = [`CONNECT ${hostname}:${port} HTTP/1.1`]

connectReq.push(`Host: ${hostname}:${port}`)

if (proxy.auth) {
connectReq.push(`Proxy-Authorization: Basic ${Buffer.from(proxy.auth).toString('base64')}`)
if (proxyAuth) {
connectReq.push(`Proxy-Authorization: Basic ${Buffer.from(proxyAuth).toString('base64')}`)
}

return connectReq.join(CRLF) + _.repeat(CRLF, 2)
Expand Down Expand Up @@ -430,7 +430,7 @@ class HttpsAgent extends https.Agent {
proxySocket.once('error', onError)
proxySocket.once('data', onData)

const connectReq = buildConnectReqHead(hostname, port, proxy)
const connectReq = buildConnectReqHead(hostname, port, proxy.auth)

proxySocket.setNoDelay(true)
proxySocket.write(connectReq)
Expand Down
86 changes: 73 additions & 13 deletions packages/network/lib/client-certificates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,25 +71,56 @@ export class UrlMatcher {
* 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: Record<'default' | string, ClientCertificates> = {
default: new ClientCertificates(),
}
clientCertificates: ClientCertificates
url: string
subjects: string
subjects: string = ''
pathnameLength: number
matchRule: ParsedUrl | undefined

constructor (url: string) {
this.url = url
this.pathnameLength = new URL(url).pathname.length
}

addSubject (subject: string) {
if (!this.subjects) {
this.subjects = subject
} else {
this.subjects = `${this.subjects} - ${subject}`
}
}

addCA (ca: Buffer) {
this.clientCertificates.default.ca.push(ca)
}

getCA () {
return this.clientCertificates.default.ca
}

addCertGroup (group: string) {
if (!this.clientCertificates[group]) {
this.clientCertificates[group] = new ClientCertificates()
}
}

getCertificates (group: string | null): ClientCertificates | null {
const certGroup = group || 'default'
const isDefault = group === null || group === 'default'
const certs = this.clientCertificates[certGroup]

if (!certs || (!isDefault && certs.cert.length === 0 && certs.pfx.length === 0)) {
debug(`no client certificates found for url '${this.url}' and group '${certGroup}'`)

return null
}

certs.ca = this.clientCertificates.default.ca

return certs
}
}

/**
Expand Down Expand Up @@ -125,6 +156,7 @@ export class PfxCertificate {

export class ClientCertificateStore {
private _urlClientCertificates: UrlClientCertificates[] = []
private certGroup: string | null = null

addClientCertificatesForUrl (cert: UrlClientCertificates) {
debug(
Expand All @@ -140,6 +172,16 @@ export class ClientCertificateStore {

cert.matchRule = UrlMatcher.buildMatcherRule(cert.url)
this._urlClientCertificates.push(cert)

this.clearCertGroup()
}

selectCertGroup (group: string | null) {
this.certGroup = group
}

clearCertGroup () {
this.certGroup = null
}

getClientCertificateAgentOptionsForUrl (requestUrl: Url): ClientCertificates | null {
Expand All @@ -150,6 +192,7 @@ export class ClientCertificateStore {
return null
}

const certGroup = this.certGroup || 'default'
const port = !requestUrl.port ? undefined : parseInt(requestUrl.port)
const matchingCerts = this._urlClientCertificates.filter((cert) => {
return UrlMatcher.matchUrl(requestUrl.hostname, requestUrl.path, port, cert.matchRule)
Expand All @@ -165,7 +208,7 @@ export class ClientCertificateStore {
`using client certificate(s) '${matchingCerts[0].subjects}' for url '${requestUrl.href}'`,
)

return matchingCerts[0].clientCertificates
return matchingCerts[0].getCertificates(certGroup)
default:
matchingCerts.sort((a, b) => {
return b.pathnameLength - a.pathnameLength
Expand All @@ -175,7 +218,7 @@ export class ClientCertificateStore {
`using client certificate(s) '${matchingCerts[0].subjects}' for url '${requestUrl.href}'`,
)

return matchingCerts[0].clientCertificates
return matchingCerts[0].getCertificates(certGroup)
}
}

Expand All @@ -185,9 +228,18 @@ export class ClientCertificateStore {

clear (): void {
this._urlClientCertificates = []
this.clearCertGroup()
}
}

export function chooseClientCertificateGroup (group: string | null) {
clientCertificateStore.selectCertGroup(group)
}

export function clearClientCertificateGroup () {
clientCertificateStore.clearCertGroup()
}

/**
* 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
Expand Down Expand Up @@ -222,7 +274,7 @@ export function loadClientCertificateConfig (config) {
throw new Error(`Cannot parse CA cert: ${error.message}`)
}

urlClientCertificates.clientCertificates.ca.push(caRaw)
urlClientCertificates.addCA(caRaw)
}
})
}
Expand All @@ -232,6 +284,8 @@ export function loadClientCertificateConfig (config) {
}

item.certs.forEach((cert) => {
const certGroup = cert.group ?? 'default'

if (!cert || (!cert.cert && !cert.pfx)) {
throw new Error('Either PEM or PFX must be supplied')
}
Expand All @@ -255,7 +309,8 @@ export function loadClientCertificateConfig (config) {
throw new Error(`Cannot parse PEM cert: ${error.message}`)
}

urlClientCertificates.clientCertificates.cert.push(pemRaw)
urlClientCertificates.addCertGroup(certGroup)
urlClientCertificates.clientCertificates[certGroup].cert.push(pemRaw)

let passphrase: string | undefined = undefined

Expand Down Expand Up @@ -283,15 +338,17 @@ export function loadClientCertificateConfig (config) {
throw new Error(`Cannot parse PEM key: ${error.message}`)
}

urlClientCertificates.clientCertificates.key.push(
urlClientCertificates.clientCertificates[certGroup].key.push(
new PemKey(pemKeyRaw, passphrase),
)

const subject = extractSubjectFromPem(pemParsed)

urlClientCertificates.addSubject(subject)

debug(
`loaded client PEM certificate: ${subject} for url: ${urlClientCertificates.url}`,
certGroup !== 'default' ? `(group: ${certGroup})` : '',
)
}

Expand All @@ -311,15 +368,18 @@ export function loadClientCertificateConfig (config) {
const pfxRaw = loadBinaryFromFile(cert.pfx)
const pfxParsed = loadPfx(pfxRaw, passphrase)

urlClientCertificates.clientCertificates.pfx.push(
urlClientCertificates.addCertGroup(certGroup)
urlClientCertificates.clientCertificates[certGroup].pfx.push(
new PfxCertificate(pfxRaw, passphrase),
)

const subject = extractSubjectFromPfx(pfxParsed)

urlClientCertificates.addSubject(subject)

debug(
`loaded client PFX certificate: ${subject} for url: ${urlClientCertificates.url}`,
certGroup !== 'default' ? `(group: ${certGroup})` : '',
)
}
})
Expand Down
Loading