Skip to content

Commit 954847c

Browse files
internal: (studio) add manifest for all of the cloud delivered files (#31923)
* internal: (studio) add manifest for all of the cloud delivered files * fix tests and environment variables * update strategy * fix tests * rework * Apply suggestions from code review * require manifest * require manifest * clean up * refactor * Update packages/server/lib/cloud/studio/StudioLifecycleManager.ts Co-authored-by: Copilot <[email protected]> * refactor * just use the string * try and fix test * try and fix test --------- Co-authored-by: Copilot <[email protected]>
1 parent 4293e76 commit 954847c

File tree

11 files changed

+331
-44
lines changed

11 files changed

+331
-44
lines changed

packages/server/lib/cloud/api/studio/get_studio_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 getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { studioUrl: string, projectId?: string, bundlePath: string }) => {
13+
export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: 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(studioUrl, {
@@ -32,6 +33,7 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st
3233
}
3334

3435
responseSignature = response.headers.get('x-cypress-signature')
36+
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
3537

3638
await new Promise<void>((resolve, reject) => {
3739
const writeStream = createWriteStream(bundlePath)
@@ -54,9 +56,15 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st
5456
throw new Error('Unable to get studio signature')
5557
}
5658

59+
if (!responseManifestSignature) {
60+
throw new Error('Unable to get studio manifest signature')
61+
}
62+
5763
const verified = await verifySignatureFromFile(bundlePath, responseSignature)
5864

5965
if (!verified) {
6066
throw new Error('Unable to verify studio signature')
6167
}
68+
69+
return responseManifestSignature
6270
}

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/lib/cloud/studio/StudioLifecycleManager.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ import { initializeTelemetryReporter, reportTelemetry } from './telemetry/Teleme
2222
import { telemetryManager } from './telemetry/TelemetryManager'
2323
import { BUNDLE_LIFECYCLE_MARK_NAMES, BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES } from './telemetry/constants/bundle-lifecycle'
2424
import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/initialization'
25+
import crypto from 'crypto'
2526

2627
const debug = Debug('cypress:server:studio-lifecycle-manager')
2728
const routes = require('../routes')
2829

2930
export class StudioLifecycleManager {
30-
private static hashLoadingMap: Map<string, Promise<void>> = new Map()
31+
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
3132
private static watcher: chokidar.FSWatcher | null = null
3233
private studioManagerPromise?: Promise<StudioManager | null>
3334
private studioManager?: StudioManager
@@ -157,6 +158,7 @@ export class StudioLifecycleManager {
157158
}): Promise<StudioManager> {
158159
let studioPath: string
159160
let studioHash: string
161+
let manifest: Record<string, string>
160162

161163
initializeTelemetryReporter({
162164
projectSlug: projectId,
@@ -190,17 +192,32 @@ export class StudioLifecycleManager {
190192
StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise)
191193
}
192194

193-
await hashLoadingPromise
195+
manifest = await hashLoadingPromise
194196
} else {
195197
studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH
196198
studioHash = 'local'
199+
manifest = {}
197200
}
198201

199202
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_END)
200203

201204
const serverFilePath = path.join(studioPath, 'server', 'index.js')
202205

203206
const script = await readFile(serverFilePath, 'utf8')
207+
208+
if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
209+
const expectedHash = manifest['server/index.js']
210+
const actualHash = crypto.createHash('sha256').update(script).digest('hex')
211+
212+
if (!expectedHash) {
213+
throw new Error('Expected hash for studio server script not found in manifest')
214+
}
215+
216+
if (actualHash !== expectedHash) {
217+
throw new Error('Invalid hash for studio server script')
218+
}
219+
}
220+
204221
const studioManager = new StudioManager()
205222

206223
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_START)
@@ -220,6 +237,7 @@ export class StudioLifecycleManager {
220237
asyncRetry,
221238
},
222239
shouldEnableStudio: this.cloudStudioRequested,
240+
manifest,
223241
})
224242

225243
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END)

packages/server/lib/cloud/studio/ensure_studio_bundle.ts

Lines changed: 21 additions & 5 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 { getStudioBundle } from '../api/studio/get_studio_bundle'
55
import path from 'path'
6+
import { verifySignature } from '../encryption'
67

78
interface EnsureStudioBundleOptions {
89
studioUrl: string
@@ -26,7 +27,7 @@ export const ensureStudioBundle = async ({
2627
projectId,
2728
studioPath,
2829
downloadTimeoutMs = DOWNLOAD_TIMEOUT,
29-
}: EnsureStudioBundleOptions) => {
30+
}: EnsureStudioBundleOptions): Promise<Record<string, string>> => {
3031
const bundlePath = path.join(studioPath, 'bundle.tar')
3132

3233
// First remove studioPath to ensure we have a clean slate
@@ -35,10 +36,9 @@ export const ensureStudioBundle = async ({
3536

3637
let timeoutId: NodeJS.Timeout
3738

38-
await Promise.race([
39+
const responseManifestSignature: string = await Promise.race([
3940
getStudioBundle({
4041
studioUrl,
41-
projectId,
4242
bundlePath,
4343
}),
4444
new Promise((_, reject) => {
@@ -48,10 +48,26 @@ export const ensureStudioBundle = async ({
4848
}),
4949
]).finally(() => {
5050
clearTimeout(timeoutId)
51-
})
51+
}) as string
5252

5353
await tar.extract({
5454
file: bundlePath,
5555
cwd: studioPath,
5656
})
57+
58+
const manifestPath = path.join(studioPath, 'manifest.json')
59+
60+
if (!(await pathExists(manifestPath))) {
61+
throw new Error('Unable to find studio manifest')
62+
}
63+
64+
const manifestContents = await readFile(manifestPath, 'utf8')
65+
66+
const verified = await verifySignature(manifestContents, responseManifestSignature)
67+
68+
if (!verified) {
69+
throw new Error('Unable to verify studio signature')
70+
}
71+
72+
return JSON.parse(manifestContents)
5773
}

packages/server/lib/cloud/studio/studio.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Debug from 'debug'
55
import { requireScript } from '../require_script'
66
import path from 'path'
77
import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error'
8+
import crypto, { BinaryLike } from 'crypto'
89

910
interface StudioServer { default: StudioServerDefaultShape }
1011

@@ -15,6 +16,7 @@ interface SetupOptions {
1516
projectSlug?: string
1617
cloudApi: StudioCloudApi
1718
shouldEnableStudio: boolean
19+
manifest: Record<string, string>
1820
}
1921

2022
const debug = Debug('cypress:server:studio')
@@ -41,7 +43,7 @@ export class StudioManager implements StudioManagerShape {
4143
return manager
4244
}
4345

44-
async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio }: SetupOptions): Promise<void> {
46+
async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio, manifest }: SetupOptions): Promise<void> {
4547
const { createStudioServer } = requireScript<StudioServer>(script).default
4648

4749
this._studioServer = await createStudioServer({
@@ -50,6 +52,18 @@ export class StudioManager implements StudioManagerShape {
5052
projectSlug,
5153
cloudApi,
5254
betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')),
55+
manifest,
56+
verifyHash: (contents: BinaryLike, expectedHash: string) => {
57+
// If we are running locally, we don't need to verify the signature. This
58+
// environment variable will get stripped in the binary.
59+
if (process.env.CYPRESS_LOCAL_STUDIO_PATH) {
60+
return true
61+
}
62+
63+
const actualHash = crypto.createHash('sha256').update(contents).digest('hex')
64+
65+
return actualHash === expectedHash
66+
},
5367
})
5468

5569
this.status = shouldEnableStudio ? 'ENABLED' : 'INITIALIZED'

packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ describe('getStudioBundle', () => {
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
}).getStudioBundle
4444
})
4545

@@ -53,15 +53,17 @@ describe('getStudioBundle', () => {
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
})
5963

6064
verifySignatureFromFileStub.resolves(true)
6165

62-
const projectId = '12345'
63-
64-
await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
66+
const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
6567

6668
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
6769
agent: sinon.match.any,
@@ -78,6 +80,8 @@ describe('getStudioBundle', () => {
7880
expect(writeResult).to.eq('console.log("studio bundle")')
7981

8082
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
83+
84+
expect(responseSignature).to.eq('160')
8185
})
8286

8387
it('downloads the studio bundle and extracts it after 1 fetch failure', async () => {
@@ -91,15 +95,17 @@ describe('getStudioBundle', () => {
9195
if (header === 'x-cypress-signature') {
9296
return '159'
9397
}
98+
99+
if (header === 'x-cypress-manifest-signature') {
100+
return '160'
101+
}
94102
},
95103
},
96104
})
97105

98106
verifySignatureFromFileStub.resolves(true)
99107

100-
const projectId = '12345'
101-
102-
await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
108+
const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
103109

104110
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
105111
agent: sinon.match.any,
@@ -116,16 +122,16 @@ describe('getStudioBundle', () => {
116122
expect(writeResult).to.eq('console.log("studio bundle")')
117123

118124
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
125+
126+
expect(responseSignature).to.eq('160')
119127
})
120128

121129
it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => {
122130
const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())
123131

124132
crossFetchStub.rejects(error)
125133

126-
const projectId = '12345'
127-
128-
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
134+
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
129135

130136
expect(crossFetchStub).to.be.calledThrice
131137
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
@@ -147,9 +153,7 @@ describe('getStudioBundle', () => {
147153
statusText: 'Some failure',
148154
})
149155

150-
const projectId = '12345'
151-
152-
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
156+
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
153157

154158
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
155159
agent: sinon.match.any,
@@ -164,7 +168,7 @@ describe('getStudioBundle', () => {
164168
})
165169
})
166170

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

170174
crossFetchStub.resolves({
@@ -176,15 +180,17 @@ describe('getStudioBundle', () => {
176180
if (header === 'x-cypress-signature') {
177181
return '159'
178182
}
183+
184+
if (header === 'x-cypress-manifest-signature') {
185+
return '160'
186+
}
179187
},
180188
},
181189
})
182190

183191
verifySignatureFromFileStub.resolves(false)
184192

185-
const projectId = '12345'
186-
187-
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
193+
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
188194

189195
expect(writeResult).to.eq('console.log("studio bundle")')
190196

@@ -209,13 +215,44 @@ describe('getStudioBundle', () => {
209215
statusText: 'OK',
210216
body: readStream,
211217
headers: {
212-
get: () => null,
218+
get: (header) => {
219+
if (header === 'x-cypress-manifest-signature') {
220+
return '160'
221+
}
222+
},
223+
},
224+
})
225+
226+
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejectedWith('Unable to get studio signature')
227+
228+
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
229+
agent: sinon.match.any,
230+
method: 'GET',
231+
headers: {
232+
'x-route-version': '1',
233+
'x-cypress-signature': '1',
234+
'x-os-name': 'linux',
235+
'x-cypress-version': '1.2.3',
213236
},
237+
encrypt: 'signed',
214238
})
239+
})
215240

216-
const projectId = '12345'
241+
it('throws an error if there is no manifest signature in the response headers', async () => {
242+
crossFetchStub.resolves({
243+
ok: true,
244+
statusText: 'OK',
245+
body: readStream,
246+
headers: {
247+
get: (header) => {
248+
if (header === 'x-cypress-signature') {
249+
return '159'
250+
}
251+
},
252+
},
253+
})
217254

218-
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
255+
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejectedWith('Unable to get studio manifest signature')
219256

220257
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
221258
agent: sinon.match.any,

0 commit comments

Comments
 (0)