From f24f257a4ce65149576669f5909685cd423429b2 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 16:06:49 -0500 Subject: [PATCH 1/3] internal: (studio) add manifest for all of the cloud delivered files --- .../lib/cloud/api/studio/get_studio_bundle.ts | 9 ++- packages/server/lib/cloud/encryption.ts | 4 +- .../cloud/studio/StudioLifecycleManager.ts | 13 ++++- .../lib/cloud/studio/ensure_studio_bundle.ts | 9 +-- packages/server/lib/cloud/studio/studio.ts | 14 ++++- .../api/studio/get_studio_bundle_spec.ts | 39 +++++++------ .../studio/StudioLifecycleManager_spec.ts | 57 ++++++++++++++++++- .../cloud/studio/ensure_studio_bundle_spec.ts | 10 +++- .../test/unit/cloud/studio/studio_spec.ts | 3 + .../types/src/studio/studio-server-types.ts | 3 + 10 files changed, 127 insertions(+), 34 deletions(-) diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts index 6347b3f1046c..a170bed1d375 100644 --- a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts +++ b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts @@ -10,10 +10,9 @@ import { verifySignatureFromFile } from '../../encryption' const pkg = require('@packages/root') const _delay = linearDelay(500) -export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { studioUrl: string, projectId?: string, bundlePath: string }) => { +export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise> => { let responseSignature: string | null = null - - await (asyncRetry(async () => { + const manifest = await (asyncRetry(async () => { const response = await fetch(studioUrl, { // @ts-expect-error - this is supported agent, @@ -44,6 +43,8 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st // @ts-expect-error - this is supported response.body?.pipe(writeStream) }) + + return JSON.parse(response.headers.get('x-cypress-manifest') || '{}') }, { maxAttempts: 3, retryDelay: _delay, @@ -59,4 +60,6 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st if (!verified) { throw new Error('Unable to verify studio signature') } + + 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/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 7235f6554623..d6201ca66d8d 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -22,12 +22,13 @@ import { initializeTelemetryReporter, reportTelemetry } from './telemetry/Teleme import { telemetryManager } from './telemetry/TelemetryManager' import { BUNDLE_LIFECYCLE_MARK_NAMES, BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES } from './telemetry/constants/bundle-lifecycle' import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/initialization' +import { verifySignature } from '../encryption' const debug = Debug('cypress:server:studio-lifecycle-manager') const routes = require('../routes') export class StudioLifecycleManager { - private static hashLoadingMap: Map> = new Map() + private static hashLoadingMap: Map>> = new Map() private static watcher: chokidar.FSWatcher | null = null private studioManagerPromise?: Promise private studioManager?: StudioManager @@ -157,6 +158,7 @@ export class StudioLifecycleManager { }): Promise { let studioPath: string let studioHash: string + let manifest: Record initializeTelemetryReporter({ projectSlug: projectId, @@ -190,10 +192,11 @@ export class StudioLifecycleManager { StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise) } - await hashLoadingPromise + manifest = await hashLoadingPromise } else { studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH studioHash = 'local' + manifest = {} } telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_END) @@ -201,6 +204,11 @@ export class StudioLifecycleManager { const serverFilePath = path.join(studioPath, 'server', 'index.js') const script = await readFile(serverFilePath, 'utf8') + + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && !verifySignature(script, manifest[path.join('server', 'index.js')])) { + throw new Error('Invalid signature for studio server script') + } + const studioManager = new StudioManager() telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_START) @@ -220,6 +228,7 @@ export class StudioLifecycleManager { asyncRetry, }, shouldEnableStudio: this.cloudStudioRequested, + manifest, }) telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END) diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index 5b4e09ca39b1..86f5c3467a62 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -26,7 +26,7 @@ export const ensureStudioBundle = async ({ projectId, studioPath, downloadTimeoutMs = DOWNLOAD_TIMEOUT, -}: EnsureStudioBundleOptions) => { +}: EnsureStudioBundleOptions): Promise> => { const bundlePath = path.join(studioPath, 'bundle.tar') // First remove studioPath to ensure we have a clean slate @@ -35,10 +35,9 @@ export const ensureStudioBundle = async ({ let timeoutId: NodeJS.Timeout - await Promise.race([ + const manifest = await Promise.race([ getStudioBundle({ studioUrl, - projectId, bundlePath, }), new Promise((_, reject) => { @@ -48,10 +47,12 @@ export const ensureStudioBundle = async ({ }), ]).finally(() => { clearTimeout(timeoutId) - }) + }) as Promise> await tar.extract({ file: bundlePath, cwd: studioPath, }) + + return manifest } diff --git a/packages/server/lib/cloud/studio/studio.ts b/packages/server/lib/cloud/studio/studio.ts index b095c9e8512c..803871e76646 100644 --- a/packages/server/lib/cloud/studio/studio.ts +++ b/packages/server/lib/cloud/studio/studio.ts @@ -5,6 +5,7 @@ import Debug from 'debug' import { requireScript } from '../require_script' import path from 'path' import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error' +import { verifySignature } from '../encryption' interface StudioServer { default: StudioServerDefaultShape } @@ -15,6 +16,7 @@ interface SetupOptions { projectSlug?: string cloudApi: StudioCloudApi shouldEnableStudio: boolean + manifest: Record } const debug = Debug('cypress:server:studio') @@ -41,7 +43,7 @@ export class StudioManager implements StudioManagerShape { return manager } - async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio }: SetupOptions): Promise { + async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio, manifest }: SetupOptions): Promise { const { createStudioServer } = requireScript(script).default this._studioServer = await createStudioServer({ @@ -50,6 +52,16 @@ export class StudioManager implements StudioManagerShape { projectSlug, cloudApi, betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')), + 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_STUDIO_PATH) { + return true + } + + return verifySignature(script, signature) + }, }) this.status = shouldEnableStudio ? 'ENABLED' : 'INITIALIZED' diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts index 7b22a25897d2..d5544fdc1ea7 100644 --- a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts @@ -9,6 +9,9 @@ describe('getStudioBundle', () => { let crossFetchStub: sinon.SinonStub let verifySignatureFromFileStub: sinon.SinonStub let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle + const mockManifest = { + 'server/index.js': 'abcdefg', + } beforeEach(() => { createWriteStreamStub = sinon.stub() @@ -53,15 +56,17 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest') { + return JSON.stringify(mockManifest) + } }, }, }) verifySignatureFromFileStub.resolves(true) - const projectId = '12345' - - await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + const manifest = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -78,6 +83,8 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + + expect(manifest).to.deep.eq(mockManifest) }) it('downloads the studio bundle and extracts it after 1 fetch failure', async () => { @@ -91,15 +98,17 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest') { + return JSON.stringify(mockManifest) + } }, }, }) verifySignatureFromFileStub.resolves(true) - const projectId = '12345' - - await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + const manifest = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -116,6 +125,8 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + + expect(manifest).to.deep.eq(mockManifest) }) it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => { @@ -123,9 +134,7 @@ describe('getStudioBundle', () => { crossFetchStub.rejects(error) - const projectId = '12345' - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected expect(crossFetchStub).to.be.calledThrice expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { @@ -147,9 +156,7 @@ describe('getStudioBundle', () => { statusText: 'Some failure', }) - const projectId = '12345' - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -182,9 +189,7 @@ describe('getStudioBundle', () => { verifySignatureFromFileStub.resolves(false) - const projectId = '12345' - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected expect(writeResult).to.eq('console.log("studio bundle")') @@ -213,9 +218,7 @@ describe('getStudioBundle', () => { }, }) - const projectId = '12345' - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index dbd6c31e4251..4af476570739 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -43,6 +43,8 @@ describe('StudioLifecycleManager', () => { let markStub: sinon.SinonStub let initializeTelemetryReporterStub: sinon.SinonStub let reportTelemetryStub: sinon.SinonStub + let verifySignatureStub: sinon.SinonStub + const mockContents = 'console.log("studio script")' beforeEach(() => { postStudioSessionStub = sinon.stub() @@ -65,6 +67,8 @@ describe('StudioLifecycleManager', () => { destroy: studioManagerDestroyStub.resolves(), } as unknown as StudioManager + verifySignatureStub = sinon.stub() + readFileStub = sinon.stub() reportTelemetryStub = sinon.stub() @@ -83,7 +87,7 @@ describe('StudioLifecycleManager', () => { }, }, 'fs-extra': { - readFile: readFileStub.resolves('console.log("studio script")'), + readFile: readFileStub.resolves(mockContents), }, '../get_cloud_metadata': { getCloudMetadata: sinon.stub().resolves({ @@ -113,6 +117,9 @@ describe('StudioLifecycleManager', () => { initializeTelemetryReporter: initializeTelemetryReporterStub, reportTelemetry: reportTelemetryStub, }, + '../encryption': { + verifySignature: verifySignatureStub.resolves(), + }, }).StudioLifecycleManager studioLifecycleManager = new StudioLifecycleManager() @@ -215,6 +222,13 @@ describe('StudioLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + await studioReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -237,12 +251,15 @@ describe('StudioLifecycleManager', () => { asyncRetry, }, shouldEnableStudio: false, + manifest: mockManifest, }) expect(postStudioSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') + expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).not.to.be.called @@ -292,6 +309,13 @@ describe('StudioLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + await studioReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -314,12 +338,15 @@ describe('StudioLifecycleManager', () => { asyncRetry, }, shouldEnableStudio: false, + manifest: mockManifest, }) expect(postStudioSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') + expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') @@ -389,6 +416,13 @@ describe('StudioLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + await studioReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -407,12 +441,15 @@ describe('StudioLifecycleManager', () => { asyncRetry, }, shouldEnableStudio: true, + manifest: {}, }) expect(postStudioSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).not.to.be.called + expect(readFileStub).to.be.calledWith(path.join('/path', 'to', 'studio', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') @@ -590,6 +627,15 @@ describe('StudioLifecycleManager', () => { }) describe('registerStudioReadyListener', () => { + beforeEach(() => { + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + }) + it('registers a listener that will be called when studio is ready', () => { const listener = sinon.stub() @@ -735,6 +781,15 @@ describe('StudioLifecycleManager', () => { }) describe('status tracking', () => { + beforeEach(() => { + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + }) + it('updates status and emits events when status changes', async () => { // Setup the context to test status updates // @ts-expect-error - accessing private property diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts index 2fbb9dc63d00..876fe9f126e3 100644 --- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -11,6 +11,9 @@ describe('ensureStudioBundle', () => { let readFileStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getStudioBundleStub: sinon.SinonStub = sinon.stub() + const mockManifest = { + 'server/index.js': 'abcdefg', + } beforeEach(() => { rmStub = sinon.stub() @@ -35,7 +38,7 @@ describe('ensureStudioBundle', () => { extract: extractStub.resolves(), }, '../api/studio/get_studio_bundle': { - getStudioBundle: getStudioBundleStub.resolves(), + getStudioBundle: getStudioBundleStub.resolves(mockManifest), }, })).ensureStudioBundle }) @@ -44,7 +47,7 @@ describe('ensureStudioBundle', () => { const studioPath = path.join(os.tmpdir(), 'cypress', 'studio', '123') const bundlePath = path.join(studioPath, 'bundle.tar') - await ensureStudioBundle({ + const manifest = await ensureStudioBundle({ studioPath, studioUrl: 'https://cypress.io/studio', projectId: '123', @@ -54,7 +57,6 @@ describe('ensureStudioBundle', () => { expect(ensureStub).to.be.calledWith(studioPath) expect(getStudioBundleStub).to.be.calledWith({ studioUrl: 'https://cypress.io/studio', - projectId: '123', bundlePath, }) @@ -62,6 +64,8 @@ describe('ensureStudioBundle', () => { file: bundlePath, cwd: studioPath, }) + + expect(manifest).to.deep.eq(mockManifest) }) it('should throw an error if the studio bundle download times out', async () => { diff --git a/packages/server/test/unit/cloud/studio/studio_spec.ts b/packages/server/test/unit/cloud/studio/studio_spec.ts index 0261119d46b4..245921849f4b 100644 --- a/packages/server/test/unit/cloud/studio/studio_spec.ts +++ b/packages/server/test/unit/cloud/studio/studio_spec.ts @@ -37,6 +37,9 @@ describe('lib/cloud/studio', () => { projectSlug: '1234', cloudApi: {} as any, shouldEnableStudio: true, + manifest: { + 'server/index.js': 'abcdefg', + }, }) studio = (studioManager as any)._studioServer diff --git a/packages/types/src/studio/studio-server-types.ts b/packages/types/src/studio/studio-server-types.ts index e87f6603bfe0..b332200660aa 100644 --- a/packages/types/src/studio/studio-server-types.ts +++ b/packages/types/src/studio/studio-server-types.ts @@ -3,6 +3,7 @@ import type { Router } from 'express' import type { AxiosInstance } from 'axios' import type { Socket } from 'socket.io' +import type { BinaryLike } from 'crypto' export const StudioMetricsTypes = { STUDIO_STARTED: 'studio:started', @@ -50,6 +51,8 @@ export interface StudioServerOptions { projectSlug?: string cloudApi: StudioCloudApi betterSqlite3Path: string + manifest: Record + verifySignature: (script: BinaryLike, signature: string) => boolean } export interface StudioAIInitializeOptions { From 0092de134c1d5d99e61bf7ec6ec5f604321279bf Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 21:22:53 -0500 Subject: [PATCH 2/3] fix tests and environment variables --- .../lib/cloud/studio/StudioLifecycleManager.ts | 9 +++++++-- scripts/after-pack-hook.js | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index d6201ca66d8d..23ba0ce59d25 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -205,8 +205,13 @@ export class StudioLifecycleManager { const script = await readFile(serverFilePath, 'utf8') - if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && !verifySignature(script, manifest[path.join('server', 'index.js')])) { - throw new Error('Invalid signature for studio 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_STUDIO_PATH && !verifySignature(script, signature)) { + throw new Error('Invalid signature for studio server script') + } } const studioManager = new StudioManager() diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index ea0e365c283a..632c2250d3c6 100644 --- a/scripts/after-pack-hook.js +++ b/scripts/after-pack-hook.js @@ -91,14 +91,22 @@ module.exports = async function (params) { const encryptionFileSource = await getEncryptionFileSource(encryptionFilePath) const cloudEnvironmentFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/environment.ts') const cloudEnvironmentFileSource = await getCloudEnvironmentFileSource(cloudEnvironmentFilePath) + + // Remove local protocol env const cloudApiFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/index.ts') const cloudApiFileSource = await getProtocolFileSource(cloudApiFilePath) const cloudProtocolFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/protocol.ts') const cloudProtocolFileSource = await getProtocolFileSource(cloudProtocolFilePath) + + // Remove local studio env const reportStudioErrorPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/studio/report_studio_error.ts') const reportStudioErrorFileSource = await getStudioFileSource(reportStudioErrorPath) const StudioLifecycleManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/studio/StudioLifecycleManager.ts') const StudioLifecycleManagerFileSource = await getStudioFileSource(StudioLifecycleManagerPath) + const studioProtocolFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/protocol.ts') + const studioProtocolFileSource = await getStudioFileSource(studioProtocolFilePath) + const studioPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/studio/studio.ts') + const studioPathFileSource = await getStudioFileSource(studioPath) await Promise.all([ fs.writeFile(encryptionFilePath, encryptionFileSource), @@ -107,6 +115,8 @@ module.exports = async function (params) { fs.writeFile(cloudProtocolFilePath, cloudProtocolFileSource), fs.writeFile(reportStudioErrorPath, reportStudioErrorFileSource), fs.writeFile(StudioLifecycleManagerPath, StudioLifecycleManagerFileSource), + fs.writeFile(studioProtocolFilePath, studioProtocolFileSource), + fs.writeFile(studioPath, studioPathFileSource), fs.writeFile(path.join(outputFolder, 'index.js'), binaryEntryPointSource), ]) @@ -121,6 +131,8 @@ module.exports = async function (params) { validateProtocolFile(cloudProtocolFilePath), validateStudioFile(reportStudioErrorPath), validateStudioFile(StudioLifecycleManagerPath), + validateStudioFile(studioProtocolFilePath), + validateStudioFile(studioPath), ]) await flipFuses( From 640e055d80f6d6cb42462a89781d8272b3fa4bb6 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 23:47:09 -0500 Subject: [PATCH 3/3] update strategy --- .../lib/cloud/api/studio/get_studio_bundle.ts | 16 ++--- .../cloud/studio/StudioLifecycleManager.ts | 12 ++-- .../lib/cloud/studio/ensure_studio_bundle.ts | 18 +++-- packages/server/lib/cloud/studio/studio.ts | 8 ++- .../api/studio/get_studio_bundle_spec.ts | 69 ++----------------- .../studio/StudioLifecycleManager_spec.ts | 27 ++------ .../cloud/studio/ensure_studio_bundle_spec.ts | 27 +++++++- .../types/src/studio/studio-server-types.ts | 2 +- 8 files changed, 64 insertions(+), 115 deletions(-) diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts index a170bed1d375..77bf1711ff30 100644 --- a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts +++ b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts @@ -5,14 +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 getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise> => { +export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise => { let responseSignature: string | null = null - const manifest = await (asyncRetry(async () => { + + await (asyncRetry(async () => { const response = await fetch(studioUrl, { // @ts-expect-error - this is supported agent, @@ -43,8 +43,6 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st // @ts-expect-error - this is supported response.body?.pipe(writeStream) }) - - return JSON.parse(response.headers.get('x-cypress-manifest') || '{}') }, { maxAttempts: 3, retryDelay: _delay, @@ -55,11 +53,5 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st throw new Error('Unable to get studio signature') } - const verified = await verifySignatureFromFile(bundlePath, responseSignature) - - if (!verified) { - throw new Error('Unable to verify studio signature') - } - - return manifest + return responseSignature } diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 23ba0ce59d25..391b81363aa5 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -22,7 +22,7 @@ import { initializeTelemetryReporter, reportTelemetry } from './telemetry/Teleme import { telemetryManager } from './telemetry/TelemetryManager' import { BUNDLE_LIFECYCLE_MARK_NAMES, BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES } from './telemetry/constants/bundle-lifecycle' import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/initialization' -import { verifySignature } from '../encryption' +import crypto from 'crypto' const debug = Debug('cypress:server:studio-lifecycle-manager') const routes = require('../routes') @@ -205,12 +205,14 @@ export class StudioLifecycleManager { 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_STUDIO_PATH && !verifySignature(script, signature)) { - throw new Error('Invalid signature for studio server script') + if (expectedHash) { + const actualHash = crypto.createHash('sha256').update(script).digest('hex') + + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && actualHash !== expectedHash) { + throw new Error('Invalid hash for studio server script') } } diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index 86f5c3467a62..b11106e9e4a0 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -1,8 +1,9 @@ -import { remove, ensureDir } from 'fs-extra' +import { remove, ensureDir, readFile } from 'fs-extra' import tar from 'tar' import { getStudioBundle } from '../api/studio/get_studio_bundle' import path from 'path' +import { verifySignature } from '../encryption' interface EnsureStudioBundleOptions { studioUrl: string @@ -35,7 +36,7 @@ export const ensureStudioBundle = async ({ let timeoutId: NodeJS.Timeout - const manifest = await Promise.race([ + const responseSignature = await Promise.race([ getStudioBundle({ studioUrl, bundlePath, @@ -47,12 +48,21 @@ export const ensureStudioBundle = async ({ }), ]).finally(() => { clearTimeout(timeoutId) - }) as Promise> + }) as string await tar.extract({ file: bundlePath, cwd: studioPath, }) - return manifest + const manifestPath = path.join(studioPath, 'manifest.json') + const manifestContents = await readFile(manifestPath, 'utf8') + + const verified = await verifySignature(manifestContents, responseSignature) + + if (!verified) { + throw new Error('Unable to verify studio signature') + } + + return JSON.parse(manifestContents) } diff --git a/packages/server/lib/cloud/studio/studio.ts b/packages/server/lib/cloud/studio/studio.ts index 803871e76646..7b0c4cae3624 100644 --- a/packages/server/lib/cloud/studio/studio.ts +++ b/packages/server/lib/cloud/studio/studio.ts @@ -5,7 +5,7 @@ import Debug from 'debug' import { requireScript } from '../require_script' import path from 'path' import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error' -import { verifySignature } from '../encryption' +import crypto, { BinaryLike } from 'crypto' interface StudioServer { default: StudioServerDefaultShape } @@ -53,14 +53,16 @@ export class StudioManager implements StudioManagerShape { cloudApi, betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')), 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_STUDIO_PATH) { return true } - return verifySignature(script, signature) + const actualHash = crypto.createHash('sha256').update(contents).digest('hex') + + return actualHash === expectedHash }, }) diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts index d5544fdc1ea7..a6c7bd02cb54 100644 --- a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts @@ -7,16 +7,11 @@ describe('getStudioBundle', () => { let readStream: Readable let createWriteStreamStub: sinon.SinonStub let crossFetchStub: sinon.SinonStub - let verifySignatureFromFileStub: sinon.SinonStub let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle - const mockManifest = { - 'server/index.js': 'abcdefg', - } beforeEach(() => { createWriteStreamStub = sinon.stub() crossFetchStub = sinon.stub() - verifySignatureFromFileStub = sinon.stub() readStream = Readable.from('console.log("studio bundle")') writeResult = '' @@ -34,9 +29,6 @@ describe('getStudioBundle', () => { createWriteStream: createWriteStreamStub, }, 'cross-fetch': crossFetchStub, - '../../encryption': { - verifySignatureFromFile: verifySignatureFromFileStub, - }, 'os': { platform: () => 'linux', }, @@ -56,17 +48,11 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } - - if (header === 'x-cypress-manifest') { - return JSON.stringify(mockManifest) - } }, }, }) - verifySignatureFromFileStub.resolves(true) - - const manifest = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -82,9 +68,7 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') - - expect(manifest).to.deep.eq(mockManifest) + expect(responseSignature).to.eq('159') }) it('downloads the studio bundle and extracts it after 1 fetch failure', async () => { @@ -98,17 +82,11 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } - - if (header === 'x-cypress-manifest') { - return JSON.stringify(mockManifest) - } }, }, }) - verifySignatureFromFileStub.resolves(true) - - const manifest = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -124,9 +102,7 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') - - expect(manifest).to.deep.eq(mockManifest) + expect(responseSignature).to.eq('159') }) it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => { @@ -171,43 +147,6 @@ describe('getStudioBundle', () => { }) }) - it('throws an error and returns a studio 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) - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected - - expect(writeResult).to.eq('console.log("studio bundle")') - - expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { - agent: sinon.match.any, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': '1', - 'x-os-name': 'linux', - 'x-cypress-version': '1.2.3', - }, - encrypt: 'signed', - }) - - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/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/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 4af476570739..6b97a1b204b7 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -43,7 +43,6 @@ describe('StudioLifecycleManager', () => { let markStub: sinon.SinonStub let initializeTelemetryReporterStub: sinon.SinonStub let reportTelemetryStub: sinon.SinonStub - let verifySignatureStub: sinon.SinonStub const mockContents = 'console.log("studio script")' beforeEach(() => { @@ -67,8 +66,6 @@ describe('StudioLifecycleManager', () => { destroy: studioManagerDestroyStub.resolves(), } as unknown as StudioManager - verifySignatureStub = sinon.stub() - readFileStub = sinon.stub() reportTelemetryStub = sinon.stub() @@ -117,9 +114,6 @@ describe('StudioLifecycleManager', () => { initializeTelemetryReporter: initializeTelemetryReporterStub, reportTelemetry: reportTelemetryStub, }, - '../encryption': { - verifySignature: verifySignatureStub.resolves(), - }, }).StudioLifecycleManager studioLifecycleManager = new StudioLifecycleManager() @@ -223,11 +217,10 @@ describe('StudioLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) await studioReadyPromise @@ -258,8 +251,6 @@ describe('StudioLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') - expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).not.to.be.called @@ -310,11 +301,10 @@ describe('StudioLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) await studioReadyPromise @@ -345,8 +335,6 @@ describe('StudioLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') - expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') @@ -417,11 +405,10 @@ describe('StudioLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) await studioReadyPromise @@ -448,8 +435,6 @@ describe('StudioLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).not.to.be.called - expect(readFileStub).to.be.calledWith(path.join('/path', 'to', 'studio', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') @@ -629,11 +614,10 @@ describe('StudioLifecycleManager', () => { describe('registerStudioReadyListener', () => { beforeEach(() => { const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) }) it('registers a listener that will be called when studio is ready', () => { @@ -783,11 +767,10 @@ describe('StudioLifecycleManager', () => { describe('status tracking', () => { beforeEach(() => { const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) }) it('updates status and emits events when status changes', async () => { diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts index 876fe9f126e3..a71f5a325fb1 100644 --- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -8,9 +8,11 @@ describe('ensureStudioBundle', () => { let rmStub: sinon.SinonStub = sinon.stub() let ensureStub: sinon.SinonStub = sinon.stub() let copyStub: sinon.SinonStub = sinon.stub() - let readFileStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getStudioBundleStub: 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', } @@ -22,6 +24,7 @@ describe('ensureStudioBundle', () => { readFileStub = sinon.stub() extractStub = sinon.stub() getStudioBundleStub = sinon.stub() + verifySignatureStub = sinon.stub() ensureStudioBundle = (proxyquire('../lib/cloud/studio/ensure_studio_bundle', { os: { @@ -32,13 +35,16 @@ describe('ensureStudioBundle', () => { remove: rmStub.resolves(), ensureDir: ensureStub.resolves(), copy: copyStub.resolves(), - readFile: readFileStub.resolves('console.log("studio bundle")'), + readFile: readFileStub.resolves(JSON.stringify(mockManifest)), }, tar: { extract: extractStub.resolves(), }, '../api/studio/get_studio_bundle': { - getStudioBundle: getStudioBundleStub.resolves(mockManifest), + getStudioBundle: getStudioBundleStub.resolves(mockResponseSignature), + }, + '../encryption': { + verifySignature: verifySignatureStub.resolves(true), }, })).ensureStudioBundle }) @@ -55,6 +61,7 @@ describe('ensureStudioBundle', () => { expect(rmStub).to.be.calledWith(studioPath) expect(ensureStub).to.be.calledWith(studioPath) + expect(readFileStub).to.be.calledWith(path.join(studioPath, 'manifest.json'), 'utf8') expect(getStudioBundleStub).to.be.calledWith({ studioUrl: 'https://cypress.io/studio', bundlePath, @@ -65,9 +72,23 @@ describe('ensureStudioBundle', () => { cwd: studioPath, }) + expect(verifySignatureStub).to.be.calledWith(JSON.stringify(mockManifest), mockResponseSignature) + expect(manifest).to.deep.eq(mockManifest) }) + it('should throw an error if the studio bundle signature is invalid', async () => { + verifySignatureStub.resolves(false) + + const ensureStudioBundlePromise = ensureStudioBundle({ + studioPath: '/tmp/cypress/studio/123', + studioUrl: 'https://cypress.io/studio', + projectId: '123', + }) + + await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to verify studio signature') + }) + it('should throw an error if the studio bundle download times out', async () => { getStudioBundleStub.callsFake(() => { return new Promise((resolve) => { diff --git a/packages/types/src/studio/studio-server-types.ts b/packages/types/src/studio/studio-server-types.ts index b332200660aa..2f9489b5a093 100644 --- a/packages/types/src/studio/studio-server-types.ts +++ b/packages/types/src/studio/studio-server-types.ts @@ -52,7 +52,7 @@ export interface StudioServerOptions { cloudApi: StudioCloudApi betterSqlite3Path: string manifest: Record - verifySignature: (script: BinaryLike, signature: string) => boolean + verifyHash: (contents: BinaryLike, expectedHash: string) => boolean } export interface StudioAIInitializeOptions {