Skip to content

Commit 8588a20

Browse files
chore: (cy.prompt) add manifest for all of the cloud delivered files (#31922)
* chore: (cy.prompt) add manifest for all of the cloud delivered files * fix tests and remove environment variables * update strategy * fix build * rework * require manifest * clean up * refactor * refactor * Update packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts Co-authored-by: Matt Schile <[email protected]> * fix test --------- Co-authored-by: Matt Schile <[email protected]>
1 parent 788ee8d commit 8588a20

File tree

13 files changed

+291
-21
lines changed

13 files changed

+291
-21
lines changed

packages/driver/src/cypress/error_messages.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1340,7 +1340,7 @@ export default {
13401340
message: stripIndent`\
13411341
Timed out waiting for cy.prompt Cloud code:
13421342
1343-
- ${obj.error.code}: ${obj.error.message}
1343+
- ${obj.error.code ? `${obj.error.code}: ` : ''}${obj.error.message}
13441344
13451345
Check your network connection and system configuration.
13461346
`,

packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import { verifySignatureFromFile } from '../../encryption'
1010
const pkg = require('@packages/root')
1111
const _delay = linearDelay(500)
1212

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

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

3637
responseSignature = response.headers.get('x-cypress-signature')
38+
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
3739

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

61+
if (!responseManifestSignature) {
62+
throw new Error('Unable to get cy-prompt manifest signature')
63+
}
64+
5965
const verified = await verifySignatureFromFile(bundlePath, responseSignature)
6066

6167
if (!verified) {
6268
throw new Error('Unable to verify cy-prompt signature')
6369
}
70+
71+
return responseManifestSignature
6472
}

packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle'
1313
import chokidar from 'chokidar'
1414
import { getCloudMetadata } from '../get_cloud_metadata'
1515
import type { CyPromptAuthenticatedUserShape } from '@packages/types'
16+
import crypto from 'crypto'
1617

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

1920
export class CyPromptLifecycleManager {
20-
private static hashLoadingMap: Map<string, Promise<void>> = new Map()
21+
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
2122
private static watcher: chokidar.FSWatcher | null = null
2223
private cyPromptManagerPromise?: Promise<{
2324
cyPromptManager?: CyPromptManager
@@ -124,6 +125,7 @@ export class CyPromptLifecycleManager {
124125
}): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> {
125126
let cyPromptHash: string
126127
let cyPromptPath: string
128+
let manifest: Record<string, string>
127129

128130
const currentProjectOptions = await getProjectOptions()
129131
const projectId = currentProjectOptions.projectSlug
@@ -148,15 +150,30 @@ export class CyPromptLifecycleManager {
148150
CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise)
149151
}
150152

151-
await hashLoadingPromise
153+
manifest = await hashLoadingPromise
152154
} else {
153155
cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH
154156
cyPromptHash = 'local'
157+
manifest = {}
155158
}
156159

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

159162
const script = await readFile(serverFilePath, 'utf8')
163+
164+
if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) {
165+
const expectedHash = manifest[path.posix.join('server', 'index.js')]
166+
const actualHash = crypto.createHash('sha256').update(script).digest('hex')
167+
168+
if (!expectedHash) {
169+
throw new Error('Expected hash for cy prompt server script not found in manifest')
170+
}
171+
172+
if (actualHash !== expectedHash) {
173+
throw new Error('Invalid hash for cy prompt server script')
174+
}
175+
}
176+
160177
const cyPromptManager = new CyPromptManager()
161178

162179
const { cloudUrl } = await getCloudMetadata(cloudDataSource)
@@ -172,6 +189,7 @@ export class CyPromptLifecycleManager {
172189
asyncRetry,
173190
},
174191
getProjectOptions,
192+
manifest,
175193
})
176194

177195
debug('cy prompt is ready')

packages/server/lib/cloud/cy-prompt/CyPromptManager.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Router } from 'express'
33
import Debug from 'debug'
44
import { requireScript } from '../require_script'
55
import type { Socket } from 'socket.io'
6+
import crypto, { BinaryLike } from 'crypto'
67

78
interface CyPromptServer { default: CyPromptServerDefaultShape }
89

@@ -18,6 +19,7 @@ interface SetupOptions {
1819
record?: boolean
1920
key?: string
2021
}>
22+
manifest: Record<string, string>
2123
}
2224

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

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

3234
this._cyPromptServer = await createCyPromptServer({
3335
cyPromptHash,
3436
cyPromptPath,
3537
cloudApi,
3638
getProjectOptions,
39+
manifest,
40+
verifyHash: (contents: BinaryLike, expectedHash: string) => {
41+
// If we are running locally, we don't need to verify the signature. This
42+
// environment variable will get stripped in the binary.
43+
if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) {
44+
return true
45+
}
46+
47+
const actualHash = crypto.createHash('sha256').update(contents).digest('hex')
48+
49+
return actualHash === expectedHash
50+
},
3751
})
3852

3953
this.status = 'INITIALIZED'

packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { remove, ensureDir } from 'fs-extra'
1+
import { remove, ensureDir, readFile, pathExists } from 'fs-extra'
22

33
import tar from 'tar'
44
import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle'
55
import path from 'path'
6+
import { verifySignature } from '../encryption'
67

78
const DOWNLOAD_TIMEOUT = 30000
89

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

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

3132
let timeoutId: NodeJS.Timeout
3233

33-
await Promise.race([
34+
const responseManifestSignature: string = await Promise.race([
3435
getCyPromptBundle({
3536
cyPromptUrl,
3637
projectId,
@@ -43,10 +44,26 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI
4344
}),
4445
]).finally(() => {
4546
clearTimeout(timeoutId)
46-
})
47+
}) as string
4748

4849
await tar.extract({
4950
file: bundlePath,
5051
cwd: cyPromptPath,
5152
})
53+
54+
const manifestPath = path.join(cyPromptPath, 'manifest.json')
55+
56+
if (!(await pathExists(manifestPath))) {
57+
throw new Error('Unable to find cy-prompt manifest')
58+
}
59+
60+
const manifestContents = await readFile(manifestPath, 'utf8')
61+
62+
const verified = await verifySignature(manifestContents, responseManifestSignature)
63+
64+
if (!verified) {
65+
throw new Error('Unable to verify cy-prompt signature')
66+
}
67+
68+
return JSON.parse(manifestContents)
5269
}

packages/server/lib/cloud/encryption.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import crypto from 'crypto'
1+
import crypto, { BinaryLike } from 'crypto'
22
import { TextEncoder, promisify } from 'util'
33
import { generalDecrypt, GeneralJWE } from 'jose'
44
import base64Url from 'base64url'
@@ -37,7 +37,7 @@ export interface EncryptRequestData {
3737
secretKey: crypto.KeyObject
3838
}
3939

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

4343
verify.update(body)

packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ describe('getCyPromptBundle', () => {
3131
createWriteStream: createWriteStreamStub,
3232
},
3333
'cross-fetch': crossFetchStub,
34-
'../../encryption': {
35-
verifySignatureFromFile: verifySignatureFromFileStub,
36-
},
3734
'os': {
3835
platform: () => 'linux',
3936
},
4037
'@packages/root': {
4138
version: '1.2.3',
4239
},
40+
'../../encryption': {
41+
verifySignatureFromFile: verifySignatureFromFileStub,
42+
},
4343
}).getCyPromptBundle
4444
})
4545

@@ -53,6 +53,10 @@ describe('getCyPromptBundle', () => {
5353
if (header === 'x-cypress-signature') {
5454
return '159'
5555
}
56+
57+
if (header === 'x-cypress-manifest-signature') {
58+
return '160'
59+
}
5660
},
5761
},
5862
})
@@ -61,7 +65,7 @@ describe('getCyPromptBundle', () => {
6165

6266
const projectId = '12345'
6367

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

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

8286
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')
87+
88+
expect(responseSignature).to.eq('160')
8389
})
8490

8591
it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => {
@@ -93,6 +99,10 @@ describe('getCyPromptBundle', () => {
9399
if (header === 'x-cypress-signature') {
94100
return '159'
95101
}
102+
103+
if (header === 'x-cypress-manifest-signature') {
104+
return '160'
105+
}
96106
},
97107
},
98108
})
@@ -101,7 +111,7 @@ describe('getCyPromptBundle', () => {
101111

102112
const projectId = '12345'
103113

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

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

122132
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')
133+
134+
expect(responseSignature).to.eq('160')
123135
})
124136

125137
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', () => {
184196
if (header === 'x-cypress-signature') {
185197
return '159'
186198
}
199+
200+
if (header === 'x-cypress-manifest-signature') {
201+
return '160'
202+
}
187203
},
188204
},
189205
})
@@ -219,13 +235,50 @@ describe('getCyPromptBundle', () => {
219235
statusText: 'OK',
220236
body: readStream,
221237
headers: {
222-
get: () => null,
238+
get: (header) => {
239+
if (header === 'x-cypress-manifest-signature') {
240+
return '160'
241+
}
242+
},
223243
},
224244
})
225245

226246
const projectId = '12345'
227247

228-
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected
248+
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')
249+
250+
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
251+
agent: sinon.match.any,
252+
method: 'GET',
253+
headers: {
254+
'x-route-version': '1',
255+
'x-cypress-signature': '1',
256+
'x-cypress-project-slug': '12345',
257+
'x-cypress-cy-prompt-mount-version': '1',
258+
'x-os-name': 'linux',
259+
'x-cypress-version': '1.2.3',
260+
},
261+
encrypt: 'signed',
262+
})
263+
})
264+
265+
it('throws an error if there is no manifest signature in the response headers', async () => {
266+
crossFetchStub.resolves({
267+
ok: true,
268+
statusText: 'OK',
269+
body: readStream,
270+
headers: {
271+
get: (header) => {
272+
if (header === 'x-cypress-signature') {
273+
return '159'
274+
}
275+
},
276+
},
277+
})
278+
279+
const projectId = '12345'
280+
281+
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')
229282

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

0 commit comments

Comments
 (0)