Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/driver/src/cypress/error_messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1340,7 +1340,7 @@ export default {
message: stripIndent`\
Timed out waiting for cy.prompt Cloud code:

- ${obj.error.code}: ${obj.error.message}
- ${obj.error.code ? `${obj.error.code}: ` : ''}${obj.error.message}

Check your network connection and system configuration.
`,
Expand Down
10 changes: 9 additions & 1 deletion packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { verifySignatureFromFile } from '../../encryption'
const pkg = require('@packages/root')
const _delay = linearDelay(500)

export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }) => {
export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise<string> => {
let responseSignature: string | null = null
let responseManifestSignature: string | null = null

await (asyncRetry(async () => {
const response = await fetch(cyPromptUrl, {
Expand All @@ -34,6 +35,7 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }:
}

responseSignature = response.headers.get('x-cypress-signature')
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')

await new Promise<void>((resolve, reject) => {
const writeStream = createWriteStream(bundlePath)
Expand All @@ -56,9 +58,15 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }:
throw new Error('Unable to get cy-prompt signature')
}

if (!responseManifestSignature) {
throw new Error('Unable to get cy-prompt manifest signature')
}

const verified = await verifySignatureFromFile(bundlePath, responseSignature)

if (!verified) {
throw new Error('Unable to verify cy-prompt signature')
}

return responseManifestSignature
}
22 changes: 20 additions & 2 deletions packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle'
import chokidar from 'chokidar'
import { getCloudMetadata } from '../get_cloud_metadata'
import type { CyPromptAuthenticatedUserShape } from '@packages/types'
import crypto from 'crypto'

const debug = Debug('cypress:server:cy-prompt-lifecycle-manager')

export class CyPromptLifecycleManager {
private static hashLoadingMap: Map<string, Promise<void>> = new Map()
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
private static watcher: chokidar.FSWatcher | null = null
private cyPromptManagerPromise?: Promise<{
cyPromptManager?: CyPromptManager
Expand Down Expand Up @@ -124,6 +125,7 @@ export class CyPromptLifecycleManager {
}): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> {
let cyPromptHash: string
let cyPromptPath: string
let manifest: Record<string, string>

const currentProjectOptions = await getProjectOptions()
const projectId = currentProjectOptions.projectSlug
Expand All @@ -148,15 +150,30 @@ export class CyPromptLifecycleManager {
CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise)
}

await hashLoadingPromise
manifest = await hashLoadingPromise
} else {
cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH
cyPromptHash = 'local'
manifest = {}
}

const serverFilePath = path.join(cyPromptPath, 'server', 'index.js')

const script = await readFile(serverFilePath, 'utf8')

if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) {
const expectedHash = manifest[path.posix.join('server', 'index.js')]
const actualHash = crypto.createHash('sha256').update(script).digest('hex')

if (!expectedHash) {
throw new Error('Expected hash for cy prompt server script not found in manifest')
}

if (actualHash !== expectedHash) {
throw new Error('Invalid hash for cy prompt server script')
}
}

const cyPromptManager = new CyPromptManager()

const { cloudUrl } = await getCloudMetadata(cloudDataSource)
Expand All @@ -172,6 +189,7 @@ export class CyPromptLifecycleManager {
asyncRetry,
},
getProjectOptions,
manifest,
})

debug('cy prompt is ready')
Expand Down
16 changes: 15 additions & 1 deletion packages/server/lib/cloud/cy-prompt/CyPromptManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Router } from 'express'
import Debug from 'debug'
import { requireScript } from '../require_script'
import type { Socket } from 'socket.io'
import crypto, { BinaryLike } from 'crypto'

interface CyPromptServer { default: CyPromptServerDefaultShape }

Expand All @@ -18,6 +19,7 @@ interface SetupOptions {
record?: boolean
key?: string
}>
manifest: Record<string, string>
}

const debug = Debug('cypress:server:cy-prompt')
Expand All @@ -26,14 +28,26 @@ export class CyPromptManager implements CyPromptManagerShape {
status: CyPromptStatus = 'NOT_INITIALIZED'
private _cyPromptServer: CyPromptServerShape | undefined

async setup ({ script, cyPromptPath, cyPromptHash, getProjectOptions, cloudApi }: SetupOptions): Promise<void> {
async setup ({ script, cyPromptPath, cyPromptHash, getProjectOptions, cloudApi, manifest }: SetupOptions): Promise<void> {
const { createCyPromptServer } = requireScript<CyPromptServer>(script).default

this._cyPromptServer = await createCyPromptServer({
cyPromptHash,
cyPromptPath,
cloudApi,
getProjectOptions,
manifest,
verifyHash: (contents: BinaryLike, expectedHash: string) => {
// If we are running locally, we don't need to verify the signature. This
// environment variable will get stripped in the binary.
if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) {
return true
}

const actualHash = crypto.createHash('sha256').update(contents).digest('hex')

return actualHash === expectedHash
},
})

this.status = 'INITIALIZED'
Expand Down
25 changes: 21 additions & 4 deletions packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { remove, ensureDir } from 'fs-extra'
import { remove, ensureDir, readFile, pathExists } from 'fs-extra'

import tar from 'tar'
import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle'
import path from 'path'
import { verifySignature } from '../encryption'

const DOWNLOAD_TIMEOUT = 30000

Expand All @@ -21,7 +22,7 @@ interface EnsureCyPromptBundleOptions {
* @param options.projectId - The project ID of the cy prompt bundle
* @param options.downloadTimeoutMs - The timeout for the cy prompt bundle download
*/
export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, downloadTimeoutMs = DOWNLOAD_TIMEOUT }: EnsureCyPromptBundleOptions) => {
export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, downloadTimeoutMs = DOWNLOAD_TIMEOUT }: EnsureCyPromptBundleOptions): Promise<Record<string, string>> => {
const bundlePath = path.join(cyPromptPath, 'bundle.tar')

// First remove cyPromptPath to ensure we have a clean slate
Expand All @@ -30,7 +31,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI

let timeoutId: NodeJS.Timeout

await Promise.race([
const responseManifestSignature: string = await Promise.race([
getCyPromptBundle({
cyPromptUrl,
projectId,
Expand All @@ -43,10 +44,26 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI
}),
]).finally(() => {
clearTimeout(timeoutId)
})
}) as string

await tar.extract({
file: bundlePath,
cwd: cyPromptPath,
})

const manifestPath = path.join(cyPromptPath, 'manifest.json')

if (!(await pathExists(manifestPath))) {
throw new Error('Unable to find cy-prompt manifest')
}

const manifestContents = await readFile(manifestPath, 'utf8')

const verified = await verifySignature(manifestContents, responseManifestSignature)

if (!verified) {
throw new Error('Unable to verify cy-prompt signature')
}

return JSON.parse(manifestContents)
}
4 changes: 2 additions & 2 deletions packages/server/lib/cloud/encryption.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import crypto from 'crypto'
import crypto, { BinaryLike } from 'crypto'
import { TextEncoder, promisify } from 'util'
import { generalDecrypt, GeneralJWE } from 'jose'
import base64Url from 'base64url'
Expand Down Expand Up @@ -37,7 +37,7 @@ export interface EncryptRequestData {
secretKey: crypto.KeyObject
}

export function verifySignature (body: string, signature: string, publicKey?: crypto.KeyObject) {
export function verifySignature (body: BinaryLike, signature: string, publicKey?: crypto.KeyObject) {
const verify = crypto.createVerify('SHA256')

verify.update(body)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ describe('getCyPromptBundle', () => {
createWriteStream: createWriteStreamStub,
},
'cross-fetch': crossFetchStub,
'../../encryption': {
verifySignatureFromFile: verifySignatureFromFileStub,
},
'os': {
platform: () => 'linux',
},
'@packages/root': {
version: '1.2.3',
},
'../../encryption': {
verifySignatureFromFile: verifySignatureFromFileStub,
},
}).getCyPromptBundle
})

Expand All @@ -53,6 +53,10 @@ describe('getCyPromptBundle', () => {
if (header === 'x-cypress-signature') {
return '159'
}

if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
Expand All @@ -61,7 +65,7 @@ describe('getCyPromptBundle', () => {

const projectId = '12345'

await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })
const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match.any,
Expand All @@ -80,6 +84,8 @@ describe('getCyPromptBundle', () => {
expect(writeResult).to.eq('console.log("cy-prompt script")')

expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')

expect(responseSignature).to.eq('160')
})

it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => {
Expand All @@ -93,6 +99,10 @@ describe('getCyPromptBundle', () => {
if (header === 'x-cypress-signature') {
return '159'
}

if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
Expand All @@ -101,7 +111,7 @@ describe('getCyPromptBundle', () => {

const projectId = '12345'

await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })
const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match.any,
Expand All @@ -120,6 +130,8 @@ describe('getCyPromptBundle', () => {
expect(writeResult).to.eq('console.log("cy-prompt script")')

expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')

expect(responseSignature).to.eq('160')
})

it('throws an error and returns a cy-prompt manager in error state if the fetch fails more than twice', async () => {
Expand Down Expand Up @@ -184,6 +196,10 @@ describe('getCyPromptBundle', () => {
if (header === 'x-cypress-signature') {
return '159'
}

if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
Expand Down Expand Up @@ -219,13 +235,50 @@ describe('getCyPromptBundle', () => {
statusText: 'OK',
body: readStream,
headers: {
get: () => null,
get: (header) => {
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})

const projectId = '12345'

await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejectedWith('Unable to get cy-prompt signature')

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match.any,
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-cypress-project-slug': '12345',
'x-cypress-cy-prompt-mount-version': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})
})

it('throws an error if there is no manifest signature in the response headers', async () => {
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
},
},
})

const projectId = '12345'

await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejectedWith('Unable to get cy-prompt manifest signature')

expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match.any,
Expand Down
Loading