diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 9287733b8cd..7b2cb435cb5 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -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. `, diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts index 0eb3f3879af..7c0c4c14e5c 100644 --- a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts @@ -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 => { let responseSignature: string | null = null + let responseManifestSignature: string | null = null await (asyncRetry(async () => { const response = await fetch(cyPromptUrl, { @@ -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((resolve, reject) => { const writeStream = createWriteStream(bundlePath) @@ -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 } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 2809e815d7f..27773c6f103 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -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> = new Map() + private static hashLoadingMap: Map>> = new Map() private static watcher: chokidar.FSWatcher | null = null private cyPromptManagerPromise?: Promise<{ cyPromptManager?: CyPromptManager @@ -124,6 +125,7 @@ export class CyPromptLifecycleManager { }): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> { let cyPromptHash: string let cyPromptPath: string + let manifest: Record const currentProjectOptions = await getProjectOptions() const projectId = currentProjectOptions.projectSlug @@ -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) @@ -172,6 +189,7 @@ export class CyPromptLifecycleManager { asyncRetry, }, getProjectOptions, + manifest, }) debug('cy prompt is ready') diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index 7b72d466f7a..89d786275bb 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -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 } @@ -18,6 +19,7 @@ interface SetupOptions { record?: boolean key?: string }> + manifest: Record } const debug = Debug('cypress:server:cy-prompt') @@ -26,7 +28,7 @@ export class CyPromptManager implements CyPromptManagerShape { status: CyPromptStatus = 'NOT_INITIALIZED' private _cyPromptServer: CyPromptServerShape | undefined - async setup ({ script, cyPromptPath, cyPromptHash, getProjectOptions, cloudApi }: SetupOptions): Promise { + async setup ({ script, cyPromptPath, cyPromptHash, getProjectOptions, cloudApi, manifest }: SetupOptions): Promise { const { createCyPromptServer } = requireScript(script).default this._cyPromptServer = await createCyPromptServer({ @@ -34,6 +36,18 @@ export class CyPromptManager implements CyPromptManagerShape { 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' diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 8691fbea863..717095c0de2 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -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 @@ -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> => { const bundlePath = path.join(cyPromptPath, 'bundle.tar') // First remove cyPromptPath to ensure we have a clean slate @@ -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, @@ -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) } diff --git a/packages/server/lib/cloud/encryption.ts b/packages/server/lib/cloud/encryption.ts index 3fee7eeff6e..5f62572b368 100644 --- a/packages/server/lib/cloud/encryption.ts +++ b/packages/server/lib/cloud/encryption.ts @@ -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' @@ -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) diff --git a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts index 35fe19437b0..c5e1f4ee265 100644 --- a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts @@ -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 }) @@ -53,6 +53,10 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest-signature') { + return '160' + } }, }, }) @@ -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, @@ -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 () => { @@ -93,6 +99,10 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest-signature') { + return '160' + } }, }, }) @@ -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, @@ -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 () => { @@ -184,6 +196,10 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest-signature') { + return '160' + } }, }, }) @@ -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, diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index 091d9fd0081..311baa4a1db 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -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() @@ -51,7 +52,7 @@ describe('CyPromptLifecycleManager', () => { }, }, 'fs-extra': { - readFile: readFileStub.resolves('console.log("cy-prompt script")'), + readFile: readFileStub.resolves(mockContents), }, 'chokidar': { watch: watcherStub.returns({ @@ -122,6 +123,12 @@ describe('CyPromptLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + await cyPromptReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -142,6 +149,7 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: mockManifest, }) expect(postCyPromptSessionStub).to.be.calledWith({ @@ -167,6 +175,12 @@ describe('CyPromptLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + const cyPromptManager1 = await cyPromptReadyPromise1 cyPromptLifecycleManager.initializeCyPromptManager({ @@ -205,6 +219,7 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: mockManifest, }) expect(postCyPromptSessionStub).to.be.calledWith({ @@ -248,6 +263,7 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: {}, }) expect(postCyPromptSessionStub).to.be.calledWith({ @@ -281,6 +297,60 @@ describe('CyPromptLifecycleManager', () => { expect(mockCyPromptManagerPromise).to.be.present expect(await mockCyPromptManagerPromise).to.equal(updatedCyPromptManager) }) + + it('throws an error when the cy-prompt server script is not found in the manifest', async () => { + cyPromptManagerSetupStub.callsFake((args) => { + return Promise.resolve() + }) + + const mockManifest = {} + + ensureCyPromptBundleStub.resolves(mockManifest) + + cyPromptLifecycleManager.initializeCyPromptManager({ + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', + }) + + // @ts-expect-error - accessing private property + const cyPromptPromise = cyPromptLifecycleManager.cyPromptManagerPromise + + expect(cyPromptPromise).to.not.be.null + + const { error } = await cyPromptPromise + + expect(error.message).to.equal('Expected hash for cy prompt server script not found in manifest') + }) + + it('throws an error when the cy-prompt server script is wrong in the manifest', async () => { + cyPromptManagerSetupStub.callsFake((args) => { + return Promise.resolve() + }) + + const mockManifest = { + 'server/index.js': 'a1', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + + cyPromptLifecycleManager.initializeCyPromptManager({ + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', + }) + + // @ts-expect-error - accessing private property + const cyPromptPromise = cyPromptLifecycleManager.cyPromptManagerPromise + + expect(cyPromptPromise).to.not.be.null + + const { error } = await cyPromptPromise + + expect(error.message).to.equal('Invalid hash for cy prompt server script') + }) }) describe('getCyPrompt', () => { @@ -304,6 +374,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() diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts index b56720b915a..7e52fe117be 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts @@ -31,6 +31,9 @@ describe('lib/cloud/cy-prompt', () => { cyPromptHash: 'abcdefg', projectSlug: '1234', cloudApi: {} as any, + manifest: { + 'server/index.js': 'abcdefg', + }, getProjectOptions: () => { return Promise.resolve({ user: { diff --git a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts index cba1100c9df..054db8fb021 100644 --- a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts @@ -9,12 +9,22 @@ describe('ensureCyPromptBundle', () => { let ensureStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getCyPromptBundleStub: sinon.SinonStub = sinon.stub() + let readFileStub: sinon.SinonStub = sinon.stub() + let verifySignatureStub: sinon.SinonStub = sinon.stub() + let pathExistsStub: sinon.SinonStub = sinon.stub() + const mockResponseSignature = '159' + const mockManifest = { + 'server/index.js': 'abcdefg', + } beforeEach(() => { rmStub = sinon.stub() ensureStub = sinon.stub() extractStub = sinon.stub() getCyPromptBundleStub = sinon.stub() + readFileStub = sinon.stub() + verifySignatureStub = sinon.stub() + pathExistsStub = sinon.stub() ensureCyPromptBundle = (proxyquire('../lib/cloud/cy-prompt/ensure_cy_prompt_bundle', { os: { @@ -24,12 +34,17 @@ describe('ensureCyPromptBundle', () => { 'fs-extra': { remove: rmStub.resolves(), ensureDir: ensureStub.resolves(), + readFile: readFileStub.resolves(JSON.stringify(mockManifest)), + pathExists: pathExistsStub.resolves(true), }, tar: { extract: extractStub.resolves(), }, '../api/cy-prompt/get_cy_prompt_bundle': { - getCyPromptBundle: getCyPromptBundleStub.resolves(), + getCyPromptBundle: getCyPromptBundleStub.resolves(mockResponseSignature), + }, + '../encryption': { + verifySignature: verifySignatureStub.resolves(true), }, })).ensureCyPromptBundle }) @@ -38,7 +53,7 @@ describe('ensureCyPromptBundle', () => { const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123') const bundlePath = path.join(cyPromptPath, 'bundle.tar') - await ensureCyPromptBundle({ + const manifest = await ensureCyPromptBundle({ cyPromptPath, cyPromptUrl: 'https://cypress.io/cy-prompt', projectId: '123', @@ -46,6 +61,7 @@ describe('ensureCyPromptBundle', () => { expect(rmStub).to.be.calledWith(cyPromptPath) expect(ensureStub).to.be.calledWith(cyPromptPath) + expect(readFileStub).to.be.calledWith(path.join(cyPromptPath, 'manifest.json'), 'utf8') expect(getCyPromptBundleStub).to.be.calledWith({ cyPromptUrl: 'https://cypress.io/cy-prompt', projectId: '123', @@ -56,6 +72,34 @@ describe('ensureCyPromptBundle', () => { file: bundlePath, cwd: cyPromptPath, }) + + expect(verifySignatureStub).to.be.calledWith(JSON.stringify(mockManifest), mockResponseSignature) + + expect(manifest).to.deep.eq(mockManifest) + }) + + it('should throw an error if the cy prompt bundle signature is invalid', async () => { + verifySignatureStub.resolves(false) + + const ensureCyPromptBundlePromise = ensureCyPromptBundle({ + cyPromptPath: '/tmp/cypress/cy-prompt/123', + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + }) + + await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to verify cy-prompt signature') + }) + + it('should throw an error if the cy prompt bundle manifest is not found', async () => { + pathExistsStub.resolves(false) + + const ensureCyPromptBundlePromise = ensureCyPromptBundle({ + cyPromptPath: '/tmp/cypress/cy-prompt/123', + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + }) + + await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to find cy-prompt manifest') }) it('should throw an error if the cy prompt bundle download times out', async () => { diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index e2aeb7d752e..e9a4d4e22cc 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -8,6 +8,7 @@ import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d' import type { Router } from 'express' import type { AxiosInstance } from 'axios' import type { Socket } from 'socket.io' +import type { BinaryLike } from 'crypto' export type CyPromptCommands = ProtocolMapping.Commands @@ -57,6 +58,8 @@ export interface CyPromptServerOptions { cyPromptPath: string projectSlug?: string cloudApi: CyPromptCloudApi + manifest: Record + verifyHash: (contents: BinaryLike, expectedHash: string) => boolean getProjectOptions: () => Promise } diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index ea0e365c283..562ba2904fc 100644 --- a/scripts/after-pack-hook.js +++ b/scripts/after-pack-hook.js @@ -19,6 +19,8 @@ const { getStudioFileSource, validateStudioFile, getIndexJscHash, + getCyPromptFileSource, + validateCyPromptFile, DUMMY_INDEX_JSC_HASH, } = require('./binary/binary-sources') const verify = require('../cli/lib/tasks/verify') @@ -100,6 +102,12 @@ module.exports = async function (params) { const StudioLifecycleManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/studio/StudioLifecycleManager.ts') const StudioLifecycleManagerFileSource = await getStudioFileSource(StudioLifecycleManagerPath) + // Remove local cy prompt env + const cyPromptLifecycleManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts') + const cyPromptLifecycleManagerFileSource = await getCyPromptFileSource(cyPromptLifecycleManagerPath) + const cyPromptManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/cy-prompt/CyPromptManager.ts') + const cyPromptManagerFileSource = await getCyPromptFileSource(cyPromptManagerPath) + await Promise.all([ fs.writeFile(encryptionFilePath, encryptionFileSource), fs.writeFile(cloudEnvironmentFilePath, cloudEnvironmentFileSource), @@ -107,6 +115,8 @@ module.exports = async function (params) { fs.writeFile(cloudProtocolFilePath, cloudProtocolFileSource), fs.writeFile(reportStudioErrorPath, reportStudioErrorFileSource), fs.writeFile(StudioLifecycleManagerPath, StudioLifecycleManagerFileSource), + fs.writeFile(cyPromptLifecycleManagerPath, cyPromptLifecycleManagerFileSource), + fs.writeFile(cyPromptManagerPath, cyPromptManagerFileSource), fs.writeFile(path.join(outputFolder, 'index.js'), binaryEntryPointSource), ]) @@ -121,6 +131,8 @@ module.exports = async function (params) { validateProtocolFile(cloudProtocolFilePath), validateStudioFile(reportStudioErrorPath), validateStudioFile(StudioLifecycleManagerPath), + validateCyPromptFile(cyPromptLifecycleManagerPath), + validateCyPromptFile(cyPromptManagerPath), ]) await flipFuses( diff --git a/scripts/binary/binary-sources.js b/scripts/binary/binary-sources.js index a6196eea094..11f1185956a 100644 --- a/scripts/binary/binary-sources.js +++ b/scripts/binary/binary-sources.js @@ -119,6 +119,16 @@ const getStudioFileSource = async (studioFilePath) => { return fileContents.replaceAll('process.env.CYPRESS_LOCAL_STUDIO_PATH', 'undefined') } +const getCyPromptFileSource = async (cyPromptFilePath) => { + const fileContents = await fs.readFile(cyPromptFilePath, 'utf8') + + if (!fileContents.includes('process.env.CYPRESS_LOCAL_CY_PROMPT_PATH')) { + throw new Error(`Expected to find CYPRESS_LOCAL_CY_PROMPT_PATH in cy prompt file`) + } + + return fileContents.replaceAll('process.env.CYPRESS_LOCAL_CY_PROMPT_PATH', 'undefined') +} + const validateProtocolFile = async (protocolFilePath) => { const afterReplaceProtocol = await fs.readFile(protocolFilePath, 'utf8') @@ -135,6 +145,14 @@ const validateStudioFile = async (studioFilePath) => { } } +const validateCyPromptFile = async (cyPromptFilePath) => { + const afterReplaceCyPrompt = await fs.readFile(cyPromptFilePath, 'utf8') + + if (afterReplaceCyPrompt.includes('process.env.CYPRESS_LOCAL_CY_PROMPT_PATH')) { + throw new Error(`Expected process.env.CYPRESS_LOCAL_CY_PROMPT_PATH to be stripped from cy prompt file`) + } +} + module.exports = { getBinaryEntryPointSource, getBinaryByteNodeEntryPointSource, @@ -147,6 +165,8 @@ module.exports = { validateProtocolFile, getStudioFileSource, validateStudioFile, + getCyPromptFileSource, + validateCyPromptFile, getIndexJscHash, DUMMY_INDEX_JSC_HASH, }