From 4da09bc4ece6cc1543fb1a9600a18a59eb9e4b96 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 15:48:29 -0500 Subject: [PATCH 1/3] chore: (cy.prompt) add manifest for all of the cloud delivered files --- .../api/cy-prompt/get_cy_prompt_bundle.ts | 8 +++- .../cy-prompt/CyPromptLifecycleManager.ts | 13 ++++++- .../lib/cloud/cy-prompt/CyPromptManager.ts | 14 ++++++- .../cy-prompt/ensure_cy_prompt_bundle.ts | 8 ++-- packages/server/lib/cloud/encryption.ts | 4 +- .../cy-prompt/get_cy_prompt_bundle_spec.ts | 20 +++++++++- .../CyPromptLifecycleManager_spec.ts | 38 ++++++++++++++++++- .../cloud/cy-prompt/CyPromptManager_spec.ts | 4 ++ .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 9 ++++- .../src/cy-prompt/cy-prompt-server-types.ts | 3 ++ 10 files changed, 106 insertions(+), 15 deletions(-) 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 0eb3f3879afa..f10b666046d8 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,10 +10,10 @@ 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 - await (asyncRetry(async () => { + const manifest = await (asyncRetry(async () => { const response = await fetch(cyPromptUrl, { // @ts-expect-error - this is supported agent, @@ -46,6 +46,8 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: // @ts-expect-error - this is supported response.body?.pipe(writeStream) }) + + return JSON.parse(response.headers.get('x-cypress-manifest') || '{}') }, { maxAttempts: 3, retryDelay: _delay, @@ -61,4 +63,6 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: if (!verified) { throw new Error('Unable to verify cy-prompt signature') } + + return manifest } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 2809e815d7f0..9b5073bb9519 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 { verifySignature } from '../encryption' 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,21 @@ 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 && !verifySignature(script, manifest[path.join('server', 'index.js')])) { + throw new Error('Invalid signature for cy prompt server script') + } + const cyPromptManager = new CyPromptManager() const { cloudUrl } = await getCloudMetadata(cloudDataSource) @@ -172,6 +180,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 3aedaf852c1d..40f7e51906bd 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 { verifySignature } from '../encryption' 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,16 @@ export class CyPromptManager implements CyPromptManagerShape { cyPromptPath, cloudApi, getProjectOptions, + manifest, + verifySignature: (script, signature) => { + // 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 + } + + return verifySignature(script, signature) + }, }) 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 8691fbea863d..068ee826cf42 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 @@ -21,7 +21,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 +30,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI let timeoutId: NodeJS.Timeout - await Promise.race([ + const manifest = await Promise.race([ getCyPromptBundle({ cyPromptUrl, projectId, @@ -43,10 +43,12 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI }), ]).finally(() => { clearTimeout(timeoutId) - }) + }) as Promise> await tar.extract({ file: bundlePath, cwd: cyPromptPath, }) + + return manifest } diff --git a/packages/server/lib/cloud/encryption.ts b/packages/server/lib/cloud/encryption.ts index b8fc892788be..2b762d0c6b1a 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 35fe19437b07..d05ac2e2d236 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 @@ -44,6 +44,8 @@ describe('getCyPromptBundle', () => { }) it('downloads the cy-prompt bundle and extracts it', async () => { + const mockManifest = { 'app/cy-prompt.js': 'abcdefg' } + crossFetchStub.resolves({ ok: true, statusText: 'OK', @@ -53,6 +55,10 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest') { + return JSON.stringify(mockManifest) + } }, }, }) @@ -61,7 +67,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 manifest = 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,9 +86,13 @@ 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(manifest).to.deep.eq(mockManifest) }) it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => { + const mockManifest = { 'app/cy-prompt.js': 'abcdefg' } + crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())) crossFetchStub.onSecondCall().resolves({ ok: true, @@ -93,6 +103,10 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest') { + return JSON.stringify(mockManifest) + } }, }, }) @@ -101,7 +115,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 manifest = 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 +134,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(manifest).to.deep.eq(mockManifest) }) it('throws an error and returns a cy-prompt manager in error state if the fetch fails more than twice', async () => { 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 091d9fd0081b..12044adbce70 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -24,12 +24,15 @@ describe('CyPromptLifecycleManager', () => { let watcherStub: sinon.SinonStub = sinon.stub() let watcherOnStub: sinon.SinonStub = sinon.stub() let watcherCloseStub: sinon.SinonStub = sinon.stub() + let verifySignatureStub: sinon.SinonStub = sinon.stub() + const mockContents: string = 'console.log("cy-prompt script")' beforeEach(() => { postCyPromptSessionStub = sinon.stub() cyPromptManagerSetupStub = sinon.stub() ensureCyPromptBundleStub = sinon.stub() cyPromptStatusChangeEmitterStub = sinon.stub() + verifySignatureStub = sinon.stub() mockCyPromptManager = { status: 'INITIALIZED', setup: cyPromptManagerSetupStub.resolves(), @@ -51,7 +54,7 @@ describe('CyPromptLifecycleManager', () => { }, }, 'fs-extra': { - readFile: readFileStub.resolves('console.log("cy-prompt script")'), + readFile: readFileStub.resolves(mockContents), }, 'chokidar': { watch: watcherStub.returns({ @@ -59,6 +62,9 @@ describe('CyPromptLifecycleManager', () => { close: watcherCloseStub, }), }, + '../encryption': { + verifySignature: verifySignatureStub, + }, }).CyPromptLifecycleManager cyPromptLifecycleManager = new CyPromptLifecycleManager() @@ -122,6 +128,13 @@ describe('CyPromptLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + await cyPromptReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -142,12 +155,15 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: mockManifest, }) expect(postCyPromptSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') + expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') @@ -167,6 +183,13 @@ describe('CyPromptLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + const cyPromptManager1 = await cyPromptReadyPromise1 cyPromptLifecycleManager.initializeCyPromptManager({ @@ -205,12 +228,15 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: mockManifest, }) expect(postCyPromptSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') + expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') @@ -248,6 +274,7 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: {}, }) expect(postCyPromptSessionStub).to.be.calledWith({ @@ -304,6 +331,15 @@ describe('CyPromptLifecycleManager', () => { }) describe('registerCyPromptReadyListener', () => { + beforeEach(() => { + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + }) + 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 a4009743292b..5634538eabff 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,10 @@ describe('lib/cloud/cy-prompt', () => { cyPromptHash: 'abcdefg', projectSlug: '1234', cloudApi: {} as any, + manifest: { + 'server/index.js': 'abcdefg', + }, + getProjectOptions: {} as any, }) cyPrompt = (cyPromptManager as any)._cyPromptServer 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 cba1100c9dfa..07dad41f02c3 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,6 +9,9 @@ describe('ensureCyPromptBundle', () => { let ensureStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getCyPromptBundleStub: sinon.SinonStub = sinon.stub() + const mockManifest = { + 'server/index.js': 'abcdefg', + } beforeEach(() => { rmStub = sinon.stub() @@ -29,7 +32,7 @@ describe('ensureCyPromptBundle', () => { extract: extractStub.resolves(), }, '../api/cy-prompt/get_cy_prompt_bundle': { - getCyPromptBundle: getCyPromptBundleStub.resolves(), + getCyPromptBundle: getCyPromptBundleStub.resolves(mockManifest), }, })).ensureCyPromptBundle }) @@ -38,7 +41,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', @@ -56,6 +59,8 @@ describe('ensureCyPromptBundle', () => { file: bundlePath, cwd: cyPromptPath, }) + + expect(manifest).to.deep.eq(mockManifest) }) 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 2d882ec5361d..79559fa466d9 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -4,6 +4,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 @@ -49,6 +50,8 @@ export interface CyPromptServerOptions { }> cyPromptPath: string cloudApi: CyPromptCloudApi + manifest: Record + verifySignature: (script: BinaryLike, signature: string) => boolean } export interface CyPromptCDPClient { From b1a192f3a4f02c458eb741b96329b99e1aa114a7 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 21:25:52 -0500 Subject: [PATCH 2/3] fix tests and remove environment variables --- .../cy-prompt/CyPromptLifecycleManager.ts | 9 +++++++-- scripts/after-pack-hook.js | 12 ++++++++++++ scripts/binary/binary-sources.js | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 9b5073bb9519..1c6bd7e61fd0 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -161,8 +161,13 @@ export class CyPromptLifecycleManager { const script = await readFile(serverFilePath, 'utf8') - if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH && !verifySignature(script, manifest[path.join('server', 'index.js')])) { - throw new Error('Invalid signature for cy prompt server script') + const signature = manifest[path.join('server', 'index.js')] + + // TODO: once the services have deployed, we should remove this check + if (signature) { + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH && !verifySignature(script, signature)) { + throw new Error('Invalid signature for cy prompt server script') + } } const cyPromptManager = new CyPromptManager() diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index ea0e365c283a..562ba2904fc4 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 a6196eea0943..7e5d0efad461 100644 --- a/scripts/binary/binary-sources.js +++ b/scripts/binary/binary-sources.js @@ -119,6 +119,14 @@ 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`) + } +} + const validateProtocolFile = async (protocolFilePath) => { const afterReplaceProtocol = await fs.readFile(protocolFilePath, 'utf8') @@ -135,6 +143,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 +163,8 @@ module.exports = { validateProtocolFile, getStudioFileSource, validateStudioFile, + getCyPromptFileSource, + validateCyPromptFile, getIndexJscHash, DUMMY_INDEX_JSC_HASH, } From d78fcb3248593e81dcb4a4675c828db3bbaa00a5 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 23:34:51 -0500 Subject: [PATCH 3/3] update strategy --- .../api/cy-prompt/get_cy_prompt_bundle.ts | 15 +--- .../cy-prompt/CyPromptLifecycleManager.ts | 14 ++-- .../lib/cloud/cy-prompt/CyPromptManager.ts | 8 +- .../cy-prompt/ensure_cy_prompt_bundle.ts | 18 ++++- .../cy-prompt/get_cy_prompt_bundle_spec.ts | 74 +------------------ .../CyPromptLifecycleManager_spec.ts | 18 +---- .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 26 ++++++- .../src/cy-prompt/cy-prompt-server-types.ts | 2 +- 8 files changed, 62 insertions(+), 113 deletions(-) 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 f10b666046d8..901e52af1968 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 @@ -5,15 +5,14 @@ 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 }): Promise> => { +export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise => { let responseSignature: string | null = null - const manifest = await (asyncRetry(async () => { + await (asyncRetry(async () => { const response = await fetch(cyPromptUrl, { // @ts-expect-error - this is supported agent, @@ -46,8 +45,6 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: // @ts-expect-error - this is supported response.body?.pipe(writeStream) }) - - return JSON.parse(response.headers.get('x-cypress-manifest') || '{}') }, { maxAttempts: 3, retryDelay: _delay, @@ -58,11 +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 manifest + return responseSignature } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 1c6bd7e61fd0..bd0e15502399 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -13,8 +13,7 @@ import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' import chokidar from 'chokidar' import { getCloudMetadata } from '../get_cloud_metadata' import type { CyPromptAuthenticatedUserShape } from '@packages/types' -import { verifySignature } from '../encryption' - +import crypto from 'crypto' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { @@ -160,13 +159,14 @@ export class CyPromptLifecycleManager { const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') const script = await readFile(serverFilePath, 'utf8') - - const signature = manifest[path.join('server', 'index.js')] + const expectedHash = manifest[path.join('server', 'index.js')] // TODO: once the services have deployed, we should remove this check - if (signature) { - if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH && !verifySignature(script, signature)) { - throw new Error('Invalid signature for cy prompt server script') + 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') } } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index 9612d8215777..89d786275bbf 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -3,7 +3,7 @@ import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' import type { Socket } from 'socket.io' -import { verifySignature } from '../encryption' +import crypto, { BinaryLike } from 'crypto' interface CyPromptServer { default: CyPromptServerDefaultShape } @@ -37,14 +37,16 @@ export class CyPromptManager implements CyPromptManagerShape { cloudApi, getProjectOptions, manifest, - verifySignature: (script, signature) => { + 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 } - return verifySignature(script, signature) + const actualHash = crypto.createHash('sha256').update(contents).digest('hex') + + return actualHash === expectedHash }, }) 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 068ee826cf42..339d40d8fd21 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 } 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 @@ -30,7 +31,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI let timeoutId: NodeJS.Timeout - const manifest = await Promise.race([ + const responseSignature = await Promise.race([ getCyPromptBundle({ cyPromptUrl, projectId, @@ -43,12 +44,21 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI }), ]).finally(() => { clearTimeout(timeoutId) - }) as Promise> + }) as string await tar.extract({ file: bundlePath, cwd: cyPromptPath, }) - return manifest + 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) } 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 d05ac2e2d236..e4d92c1885ae 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 @@ -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 = '' @@ -31,9 +29,6 @@ describe('getCyPromptBundle', () => { createWriteStream: createWriteStreamStub, }, 'cross-fetch': crossFetchStub, - '../../encryption': { - verifySignatureFromFile: verifySignatureFromFileStub, - }, 'os': { platform: () => 'linux', }, @@ -44,8 +39,6 @@ describe('getCyPromptBundle', () => { }) it('downloads the cy-prompt bundle and extracts it', async () => { - const mockManifest = { 'app/cy-prompt.js': 'abcdefg' } - crossFetchStub.resolves({ ok: true, statusText: 'OK', @@ -55,19 +48,13 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } - - if (header === 'x-cypress-manifest') { - return JSON.stringify(mockManifest) - } }, }, }) - verifySignatureFromFileStub.resolves(true) - const projectId = '12345' - const manifest = 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, @@ -85,14 +72,10 @@ 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(manifest).to.deep.eq(mockManifest) + expect(responseSignature).to.eq('159') }) it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => { - const mockManifest = { 'app/cy-prompt.js': 'abcdefg' } - crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())) crossFetchStub.onSecondCall().resolves({ ok: true, @@ -103,19 +86,13 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } - - if (header === 'x-cypress-manifest') { - return JSON.stringify(mockManifest) - } }, }, }) - verifySignatureFromFileStub.resolves(true) - const projectId = '12345' - const manifest = 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, @@ -133,9 +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(manifest).to.deep.eq(mockManifest) + 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 () => { @@ -188,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, 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 12044adbce70..e86bed83ad75 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -24,7 +24,6 @@ describe('CyPromptLifecycleManager', () => { let watcherStub: sinon.SinonStub = sinon.stub() let watcherOnStub: sinon.SinonStub = sinon.stub() let watcherCloseStub: sinon.SinonStub = sinon.stub() - let verifySignatureStub: sinon.SinonStub = sinon.stub() const mockContents: string = 'console.log("cy-prompt script")' beforeEach(() => { @@ -32,7 +31,6 @@ describe('CyPromptLifecycleManager', () => { cyPromptManagerSetupStub = sinon.stub() ensureCyPromptBundleStub = sinon.stub() cyPromptStatusChangeEmitterStub = sinon.stub() - verifySignatureStub = sinon.stub() mockCyPromptManager = { status: 'INITIALIZED', setup: cyPromptManagerSetupStub.resolves(), @@ -62,9 +60,6 @@ describe('CyPromptLifecycleManager', () => { close: watcherCloseStub, }), }, - '../encryption': { - verifySignature: verifySignatureStub, - }, }).CyPromptLifecycleManager cyPromptLifecycleManager = new CyPromptLifecycleManager() @@ -129,11 +124,10 @@ describe('CyPromptLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4', } ensureCyPromptBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) await cyPromptReadyPromise @@ -162,8 +156,6 @@ describe('CyPromptLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') - expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') @@ -184,11 +176,10 @@ describe('CyPromptLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4', } ensureCyPromptBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) const cyPromptManager1 = await cyPromptReadyPromise1 @@ -235,8 +226,6 @@ describe('CyPromptLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') - expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') @@ -333,11 +322,10 @@ describe('CyPromptLifecycleManager', () => { describe('registerCyPromptReadyListener', () => { beforeEach(() => { const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4', } ensureCyPromptBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) }) it('registers a listener that will be called when cy-prompt is ready', () => { 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 07dad41f02c3..2b254b9bd0a0 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,6 +9,9 @@ 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() + const mockResponseSignature = '159' const mockManifest = { 'server/index.js': 'abcdefg', } @@ -18,6 +21,8 @@ describe('ensureCyPromptBundle', () => { ensureStub = sinon.stub() extractStub = sinon.stub() getCyPromptBundleStub = sinon.stub() + readFileStub = sinon.stub() + verifySignatureStub = sinon.stub() ensureCyPromptBundle = (proxyquire('../lib/cloud/cy-prompt/ensure_cy_prompt_bundle', { os: { @@ -27,12 +32,16 @@ describe('ensureCyPromptBundle', () => { 'fs-extra': { remove: rmStub.resolves(), ensureDir: ensureStub.resolves(), + readFile: readFileStub.resolves(JSON.stringify(mockManifest)), }, tar: { extract: extractStub.resolves(), }, '../api/cy-prompt/get_cy_prompt_bundle': { - getCyPromptBundle: getCyPromptBundleStub.resolves(mockManifest), + getCyPromptBundle: getCyPromptBundleStub.resolves(mockResponseSignature), + }, + '../encryption': { + verifySignature: verifySignatureStub.resolves(true), }, })).ensureCyPromptBundle }) @@ -49,6 +58,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', @@ -60,9 +70,23 @@ describe('ensureCyPromptBundle', () => { 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 download times out', async () => { getCyPromptBundleStub.callsFake(() => { return new Promise((resolve) => { 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 deacdae9a443..e9a4d4e22cc1 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -59,7 +59,7 @@ export interface CyPromptServerOptions { projectSlug?: string cloudApi: CyPromptCloudApi manifest: Record - verifySignature: (script: BinaryLike, signature: string) => boolean + verifyHash: (contents: BinaryLike, expectedHash: string) => boolean getProjectOptions: () => Promise }