Skip to content

Commit ed8e7ea

Browse files
authored
chore: continuing with request to axios changes (#31915)
1 parent 4dd4e35 commit ed8e7ea

File tree

10 files changed

+489
-35
lines changed

10 files changed

+489
-35
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
2+
import * as enc from '../../encryption'
3+
import { PUBLIC_KEY_VERSION } from '../../constants'
4+
import crypto, { KeyObject } from 'crypto'
5+
import { DecryptionError } from '../cloud_request_errors'
6+
import axios from 'axios'
7+
8+
let encryptionKey: KeyObject
9+
10+
declare module 'axios' {
11+
interface AxiosRequestConfig {
12+
encrypt?: 'always' | 'signed' | boolean
13+
}
14+
}
15+
16+
const encryptRequest = async (req: InternalAxiosRequestConfig) => {
17+
if (!req.data) {
18+
throw new Error(`Cannot issue encrypted request to ${req.url} without request body`)
19+
}
20+
21+
encryptionKey ??= crypto.createSecretKey(Uint8Array.from(crypto.randomBytes(32)))
22+
23+
const { jwe } = await enc.encryptRequest({ body: req.data }, { secretKey: encryptionKey })
24+
25+
req.headers.set('x-cypress-encrypted', PUBLIC_KEY_VERSION)
26+
req.data = jwe
27+
28+
return req
29+
}
30+
31+
const signRequest = (req: InternalAxiosRequestConfig) => {
32+
req.headers.set('x-cypress-signature', PUBLIC_KEY_VERSION)
33+
34+
return req
35+
}
36+
37+
const maybeDecryptResponse = async (res: AxiosResponse) => {
38+
if (!res.config.encrypt) {
39+
return res
40+
}
41+
42+
if (res.config.encrypt === 'always' || res.headers['x-cypress-encrypted']) {
43+
try {
44+
res.data = await enc.decryptResponse(res.data, encryptionKey)
45+
} catch (e) {
46+
throw new DecryptionError(e.message)
47+
}
48+
}
49+
50+
return res
51+
}
52+
53+
const maybeDecryptErrorResponse = async (err: AxiosError<any> | Error & { error?: any, statusCode: number, isApiError?: boolean }) => {
54+
if (axios.isAxiosError(err) && err.response?.data) {
55+
if (err.config?.encrypt === 'always' || err.response?.headers['x-cypress-encrypted']) {
56+
try {
57+
if (err.response.data) {
58+
err.response.data = await enc.decryptResponse(err.response.data, encryptionKey)
59+
}
60+
} catch (e) {
61+
if (err.status && err.status >= 500 || err.status === 404) {
62+
throw err
63+
}
64+
65+
throw new DecryptionError(e.message)
66+
}
67+
}
68+
}
69+
70+
throw err
71+
}
72+
73+
const maybeVerifyResponseSignature = (res: AxiosResponse) => {
74+
if (res.config.encrypt === 'signed' && !res.headers['x-cypress-signature']) {
75+
throw new Error(`Expected signed response for ${res.config.url }`)
76+
}
77+
78+
if (res.headers['x-cypress-signature']) {
79+
const dataString = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
80+
const verified = enc.verifySignature(dataString, res.headers['x-cypress-signature'])
81+
82+
if (!verified) {
83+
throw new Error(`Unable to verify response signature for ${res.config.url}`)
84+
}
85+
}
86+
87+
return res
88+
}
89+
90+
// Always = req & res MUST be encrypted
91+
// true = req MUST be encrypted, res MAY be encrypted, signified by header
92+
// signed = verify signature of the response body
93+
export const installEncryption = (axios: AxiosInstance) => {
94+
axios.interceptors.request.use(encryptRequest, undefined, {
95+
runWhen (config) {
96+
return config.encrypt === true || config.encrypt === 'always'
97+
},
98+
})
99+
100+
axios.interceptors.request.use(signRequest, undefined, {
101+
runWhen (config) {
102+
return config.encrypt === 'signed'
103+
},
104+
})
105+
106+
axios.interceptors.response.use(maybeDecryptResponse, maybeDecryptErrorResponse)
107+
axios.interceptors.response.use(maybeVerifyResponseSignature)
108+
}

packages/server/lib/cloud/api/cloud_request.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
* The axios Cloud instance should not be used.
33
*/
44
import os from 'os'
5-
65
import followRedirects from 'follow-redirects'
76
import axios, { AxiosInstance } from 'axios'
87
import pkg from '@packages/root'
@@ -11,19 +10,43 @@ import agent from '@packages/network/lib/agent'
1110
import app_config from '../../../config/app.json'
1211
import { installErrorTransform } from './axios_middleware/transform_error'
1312
import { installLogging } from './axios_middleware/logging'
13+
import { installEncryption } from './axios_middleware/encryption'
14+
15+
export interface CreateCloudRequestOptions {
16+
/**
17+
* The baseURL for all requests for this Cloud Request instance
18+
*/
19+
baseURL?: string
20+
/**
21+
* Additional headers for the Cloud Request
22+
*/
23+
additionalHeaders?: Record<string, string>
24+
/**
25+
* Whether to include the default logging middleware
26+
* @default true
27+
*/
28+
enableLogging?: boolean
29+
/**
30+
* Whether to include the default error transformation
31+
* @default true
32+
*/
33+
enableErrorTransform?: boolean
34+
}
1435

15-
// initialized with an export for testing purposes
16-
export const _create = (options: { baseURL?: string } = {}): AxiosInstance => {
36+
// Allows us to create customized Cloud Request instances w/ different baseURL & encryption configuration
37+
export const createCloudRequest = (options: CreateCloudRequestOptions = {}): AxiosInstance => {
1738
const cfgKey = process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'development'
39+
const { baseURL = app_config[cfgKey].api_url, enableLogging = true, enableErrorTransform = true } = options
1840

1941
const instance = axios.create({
20-
baseURL: options.baseURL ?? app_config[cfgKey].api_url,
42+
baseURL,
2143
httpAgent: agent,
2244
httpsAgent: agent,
2345
headers: {
2446
'x-os-name': os.platform(),
2547
'x-cypress-version': pkg.version,
2648
'User-Agent': `cypress/${pkg.version}`,
49+
...options.additionalHeaders,
2750
},
2851
transport: {
2952
// https://github.com/axios/axios/issues/6313#issue-2198831362
@@ -43,13 +66,22 @@ export const _create = (options: { baseURL?: string } = {}): AxiosInstance => {
4366
},
4467
})
4568

46-
installLogging(instance)
47-
installErrorTransform(instance)
69+
installEncryption(instance)
70+
71+
if (enableLogging) {
72+
installLogging(instance)
73+
}
74+
75+
if (enableErrorTransform) {
76+
installErrorTransform(instance)
77+
}
4878

4979
return instance
5080
}
5181

52-
export const CloudRequest = _create()
82+
export const CloudRequest = createCloudRequest()
83+
84+
export type TCloudReqest = ReturnType<typeof createCloudRequest>
5385

5486
export const isRetryableCloudError = (error: unknown) => {
5587
// setting this env via mocha's beforeEach coerces this to a string, even if it's a boolean
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export class DecryptionError extends Error {
2+
isDecryptionError = true
3+
4+
constructor (message: string) {
5+
super(message)
6+
this.name = 'DecryptionError'
7+
}
8+
}

packages/server/lib/cloud/api/index.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { PUBLIC_KEY_VERSION } from '../constants'
3636
import type { CreateInstanceRequestBody, CreateInstanceResponse } from './create_instance'
3737

3838
import { transformError } from './axios_middleware/transform_error'
39+
import { DecryptionError } from './cloud_request_errors'
3940

4041
const THIRTY_SECONDS = humanInterval('30 seconds')
4142
const SIXTY_SECONDS = humanInterval('60 seconds')
@@ -57,15 +58,6 @@ let responseCache = {}
5758

5859
const CAPTURE_ERRORS = !process.env.CYPRESS_LOCAL_PROTOCOL_PATH
5960

60-
class DecryptionError extends Error {
61-
isDecryptionError = true
62-
63-
constructor (message: string) {
64-
super(message)
65-
this.name = 'DecryptionError'
66-
}
67-
}
68-
6961
export interface CypressRequestOptions extends OptionsWithUrl {
7062
encrypt?: boolean | 'always' | 'signed'
7163
method: string

packages/server/lib/cloud/encryption.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@ export function verifySignatureFromFile (file: string, signature: string, public
6969
// in the jose library (https://github.com/panva/jose/blob/main/src/jwe/general/encrypt.ts),
7070
// but allows us to keep track of the encrypting key locally, to optionally use it for decryption
7171
// of encrypted payloads coming back in the response body.
72-
export async function encryptRequest (params: CypressRequestOptions, publicKey?: crypto.KeyObject): Promise<EncryptRequestData> {
73-
const key = publicKey || getPublicKey()
72+
export async function encryptRequest (params: Pick<CypressRequestOptions, 'body'>, options: {
73+
publicKey?: crypto.KeyObject
74+
secretKey?: crypto.KeyObject
75+
} = {}): Promise<EncryptRequestData> {
76+
const { publicKey = getPublicKey(), secretKey = crypto.createSecretKey(crypto.randomBytes(32)) } = options
7477
const header = base64Url(JSON.stringify({ alg: 'RSA-OAEP', enc: 'A256GCM', zip: 'DEF' }))
7578
const deflated = await deflateRaw(JSON.stringify(params.body))
7679
const iv = crypto.randomBytes(12)
77-
const secretKey = crypto.createSecretKey(crypto.randomBytes(32))
7880
const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, iv, { authTagLength: 16 })
7981
const aad = new TextEncoder().encode(header)
8082

@@ -95,7 +97,7 @@ export async function encryptRequest (params: CypressRequestOptions, publicKey?:
9597
ciphertext: base64Url(encrypted),
9698
recipients: [
9799
{
98-
encrypted_key: base64Url(crypto.publicEncrypt(key, secretKey.export())),
100+
encrypted_key: base64Url(crypto.publicEncrypt(publicKey, secretKey.export())),
99101
},
100102
],
101103
tag: base64Url(cipher.getAuthTag()),

packages/server/test/unit/cloud/api/api_spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const decryptReqBodyAndRespond = ({ reqBody, resBody }, fn) => {
5555
expect(params.body).to.deep.eq(reqBody)
5656
}
5757

58-
const { secretKey, jwe } = await encryptRequest(params, publicKey)
58+
const { secretKey, jwe } = await encryptRequest(params, { publicKey })
5959

6060
if (fn) {
6161
encryption.encryptRequest.restore()

0 commit comments

Comments
 (0)