Skip to content

chore: (cy.prompt) add manifest for all of the cloud delivered files #31922

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

Open
wants to merge 4 commits into
base: feat/cy-prompt
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import os from 'os'
import { agent } from '@packages/network'
import { PUBLIC_KEY_VERSION } from '../../constants'
import { createWriteStream } from 'fs'
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

await (asyncRetry(async () => {
Expand Down Expand Up @@ -56,9 +55,5 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }:
throw new Error('Unable to get cy-prompt signature')
}

const verified = await verifySignatureFromFile(bundlePath, responseSignature)

if (!verified) {
throw new Error('Unable to verify cy-prompt signature')
}
return responseSignature
}
20 changes: 17 additions & 3 deletions packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ 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 +124,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 +149,27 @@ 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')
const expectedHash = manifest[path.join('server', 'index.js')]

// TODO: once the services have deployed, we should remove this check
if (expectedHash) {
const actualHash = crypto.createHash('sha256').update(script).digest('hex')

if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH && 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 +185,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
20 changes: 16 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 } 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 responseSignature = await Promise.race([
getCyPromptBundle({
cyPromptUrl,
projectId,
Expand All @@ -43,10 +44,21 @@ 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')
const manifestContents = await readFile(manifestPath, 'utf8')

const verified = await verifySignature(manifestContents, responseSignature)

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 @@ -7,13 +7,11 @@ describe('getCyPromptBundle', () => {
let readStream: Readable
let createWriteStreamStub: sinon.SinonStub
let crossFetchStub: sinon.SinonStub
let verifySignatureFromFileStub: sinon.SinonStub
let getCyPromptBundle: typeof import('../../../../../lib/cloud/api/cy-prompt/get_cy_prompt_bundle').getCyPromptBundle

beforeEach(() => {
createWriteStreamStub = sinon.stub()
crossFetchStub = sinon.stub()
verifySignatureFromFileStub = sinon.stub()
readStream = Readable.from('console.log("cy-prompt script")')

writeResult = ''
Expand All @@ -31,9 +29,6 @@ describe('getCyPromptBundle', () => {
createWriteStream: createWriteStreamStub,
},
'cross-fetch': crossFetchStub,
'../../encryption': {
verifySignatureFromFile: verifySignatureFromFileStub,
},
'os': {
platform: () => 'linux',
},
Expand All @@ -57,11 +52,9 @@ describe('getCyPromptBundle', () => {
},
})

verifySignatureFromFileStub.resolves(true)

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 @@ -79,7 +72,7 @@ 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('159')
})

it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => {
Expand All @@ -97,11 +90,9 @@ describe('getCyPromptBundle', () => {
},
})

verifySignatureFromFileStub.resolves(true)

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 @@ -119,7 +110,7 @@ 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('159')
})

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 @@ -172,47 +163,6 @@ describe('getCyPromptBundle', () => {
})
})

it('throws an error and returns a cy-prompt manager in error state if the signature verification fails', async () => {
verifySignatureFromFileStub.resolves(false)

crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
},
},
})

verifySignatureFromFileStub.resolves(false)

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

expect(writeResult).to.eq('console.log("cy-prompt script")')

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',
})

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

it('throws an error if there is no signature in the response headers', async () => {
crossFetchStub.resolves({
ok: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('CyPromptLifecycleManager', () => {
let watcherStub: sinon.SinonStub = sinon.stub()
let watcherOnStub: sinon.SinonStub = sinon.stub()
let watcherCloseStub: sinon.SinonStub = sinon.stub()
const mockContents: string = 'console.log("cy-prompt script")'

beforeEach(() => {
postCyPromptSessionStub = sinon.stub()
Expand Down Expand Up @@ -51,7 +52,7 @@ describe('CyPromptLifecycleManager', () => {
},
},
'fs-extra': {
readFile: readFileStub.resolves('console.log("cy-prompt script")'),
readFile: readFileStub.resolves(mockContents),
},
'chokidar': {
watch: watcherStub.returns({
Expand Down Expand Up @@ -122,6 +123,12 @@ describe('CyPromptLifecycleManager', () => {
})
})

const mockManifest = {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}

ensureCyPromptBundleStub.resolves(mockManifest)

await cyPromptReadyPromise

expect(mockCtx.update).to.be.calledOnce
Expand All @@ -142,6 +149,7 @@ describe('CyPromptLifecycleManager', () => {
asyncRetry,
},
getProjectOptions: sinon.match.func,
manifest: mockManifest,
})

expect(postCyPromptSessionStub).to.be.calledWith({
Expand All @@ -167,6 +175,12 @@ describe('CyPromptLifecycleManager', () => {
})
})

const mockManifest = {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}

ensureCyPromptBundleStub.resolves(mockManifest)

const cyPromptManager1 = await cyPromptReadyPromise1

cyPromptLifecycleManager.initializeCyPromptManager({
Expand Down Expand Up @@ -205,6 +219,7 @@ describe('CyPromptLifecycleManager', () => {
asyncRetry,
},
getProjectOptions: sinon.match.func,
manifest: mockManifest,
})

expect(postCyPromptSessionStub).to.be.calledWith({
Expand Down Expand Up @@ -248,6 +263,7 @@ describe('CyPromptLifecycleManager', () => {
asyncRetry,
},
getProjectOptions: sinon.match.func,
manifest: {},
})

expect(postCyPromptSessionStub).to.be.calledWith({
Expand Down Expand Up @@ -304,6 +320,14 @@ describe('CyPromptLifecycleManager', () => {
})

describe('registerCyPromptReadyListener', () => {
beforeEach(() => {
const mockManifest = {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}

ensureCyPromptBundleStub.resolves(mockManifest)
})

it('registers a listener that will be called when cy-prompt is ready', () => {
const listener = sinon.stub()

Expand Down
Loading