Skip to content

chore: continuing with request to axios changes #31915

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions packages/server/lib/cloud/api/axios_middleware/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import * as enc from '../../encryption'
import { PUBLIC_KEY_VERSION } from '../../constants'
import crypto, { KeyObject } from 'crypto'
import { DecryptionError } from '../cloud_request_errors'
import axios from 'axios'

let encryptionKey: KeyObject

declare module 'axios' {
interface AxiosRequestConfig {
encrypt?: 'always' | 'signed' | boolean
}
}

const encryptRequest = async (req: InternalAxiosRequestConfig) => {
if (!req.data) {
throw new Error(`Cannot issue encrypted request to ${req.url} without request body`)
}

encryptionKey ??= crypto.createSecretKey(Uint8Array.from(crypto.randomBytes(32)))

const { jwe } = await enc.encryptRequest({ body: req.data }, { secretKey: encryptionKey })

req.headers.set('x-cypress-encrypted', PUBLIC_KEY_VERSION)
req.data = jwe

return req
}

const signRequest = (req: InternalAxiosRequestConfig) => {
req.headers.set('x-cypress-signature', PUBLIC_KEY_VERSION)

return req
}

const maybeDecryptResponse = async (res: AxiosResponse) => {
if (!res.config.encrypt) {
return res
}

if (res.config.encrypt === 'always' || res.headers['x-cypress-encrypted']) {
try {
res.data = await enc.decryptResponse(res.data, encryptionKey)
} catch (e) {
throw new DecryptionError(e.message)
}
}

return res
}

const maybeDecryptErrorResponse = async (err: AxiosError<any> | Error & { error?: any, statusCode: number, isApiError?: boolean }) => {
if (axios.isAxiosError(err) && err.response?.data) {
if (err.config?.encrypt === 'always' || err.response?.headers['x-cypress-encrypted']) {
try {
if (err.response.data) {
err.response.data = await enc.decryptResponse(err.response.data, encryptionKey)
}
} catch (e) {
if (err.status && err.status >= 500 || err.status === 404) {
throw err
}

throw new DecryptionError(e.message)
}
}
}

throw err
}

const maybeVerifyResponseSignature = (res: AxiosResponse) => {
if (res.config.encrypt === 'signed' && !res.headers['x-cypress-signature']) {
throw new Error(`Expected signed response for ${res.config.url }`)
}

if (res.headers['x-cypress-signature']) {
const dataString = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
const verified = enc.verifySignature(dataString, res.headers['x-cypress-signature'])

if (!verified) {
throw new Error(`Unable to verify response signature for ${res.config.url}`)
}
}

return res
}

// Always = req & res MUST be encrypted
// true = req MUST be encrypted, res MAY be encrypted, signified by header
// signed = verify signature of the response body
export const installEncryption = (axios: AxiosInstance) => {
axios.interceptors.request.use(encryptRequest, undefined, {
runWhen (config) {
return config.encrypt === true || config.encrypt === 'always'
},
})

axios.interceptors.request.use(signRequest, undefined, {
runWhen (config) {
return config.encrypt === 'signed'
},
})

axios.interceptors.response.use(maybeDecryptResponse, maybeDecryptErrorResponse)
axios.interceptors.response.use(maybeVerifyResponseSignature)
}
46 changes: 39 additions & 7 deletions packages/server/lib/cloud/api/cloud_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* The axios Cloud instance should not be used.
*/
import os from 'os'

import followRedirects from 'follow-redirects'
import axios, { AxiosInstance } from 'axios'
import pkg from '@packages/root'
Expand All @@ -11,19 +10,43 @@ import agent from '@packages/network/lib/agent'
import app_config from '../../../config/app.json'
import { installErrorTransform } from './axios_middleware/transform_error'
import { installLogging } from './axios_middleware/logging'
import { installEncryption } from './axios_middleware/encryption'

export interface CreateCloudRequestOptions {
/**
* The baseURL for all requests for this Cloud Request instance
*/
baseURL?: string
/**
* Additional headers for the Cloud Request
*/
additionalHeaders?: Record<string, string>
/**
* Whether to include the default logging middleware
* @default true
*/
enableLogging?: boolean
/**
* Whether to include the default error transformation
* @default true
*/
enableErrorTransform?: boolean
}

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

const instance = axios.create({
baseURL: options.baseURL ?? app_config[cfgKey].api_url,
baseURL,
httpAgent: agent,
httpsAgent: agent,
headers: {
'x-os-name': os.platform(),
'x-cypress-version': pkg.version,
'User-Agent': `cypress/${pkg.version}`,
...options.additionalHeaders,
},
transport: {
// https://github.com/axios/axios/issues/6313#issue-2198831362
Expand All @@ -43,13 +66,22 @@ export const _create = (options: { baseURL?: string } = {}): AxiosInstance => {
},
})

installLogging(instance)
installErrorTransform(instance)
installEncryption(instance)

if (enableLogging) {
installLogging(instance)
}

if (enableErrorTransform) {
installErrorTransform(instance)
}

return instance
}

export const CloudRequest = _create()
export const CloudRequest = createCloudRequest()

export type TCloudReqest = ReturnType<typeof createCloudRequest>

export const isRetryableCloudError = (error: unknown) => {
// setting this env via mocha's beforeEach coerces this to a string, even if it's a boolean
Expand Down
8 changes: 8 additions & 0 deletions packages/server/lib/cloud/api/cloud_request_errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class DecryptionError extends Error {
isDecryptionError = true

constructor (message: string) {
super(message)
this.name = 'DecryptionError'
}
}
10 changes: 1 addition & 9 deletions packages/server/lib/cloud/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { PUBLIC_KEY_VERSION } from '../constants'
import type { CreateInstanceRequestBody, CreateInstanceResponse } from './create_instance'

import { transformError } from './axios_middleware/transform_error'
import { DecryptionError } from './cloud_request_errors'

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

const CAPTURE_ERRORS = !process.env.CYPRESS_LOCAL_PROTOCOL_PATH

class DecryptionError extends Error {
isDecryptionError = true

constructor (message: string) {
super(message)
this.name = 'DecryptionError'
}
}

export interface CypressRequestOptions extends OptionsWithUrl {
encrypt?: boolean | 'always' | 'signed'
method: string
Expand Down
10 changes: 6 additions & 4 deletions packages/server/lib/cloud/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ export function verifySignatureFromFile (file: string, signature: string, public
// in the jose library (https://github.com/panva/jose/blob/main/src/jwe/general/encrypt.ts),
// but allows us to keep track of the encrypting key locally, to optionally use it for decryption
// of encrypted payloads coming back in the response body.
export async function encryptRequest (params: CypressRequestOptions, publicKey?: crypto.KeyObject): Promise<EncryptRequestData> {
const key = publicKey || getPublicKey()
export async function encryptRequest (params: Pick<CypressRequestOptions, 'body'>, options: {
publicKey?: crypto.KeyObject
secretKey?: crypto.KeyObject
} = {}): Promise<EncryptRequestData> {
const { publicKey = getPublicKey(), secretKey = crypto.createSecretKey(crypto.randomBytes(32)) } = options
const header = base64Url(JSON.stringify({ alg: 'RSA-OAEP', enc: 'A256GCM', zip: 'DEF' }))
const deflated = await deflateRaw(JSON.stringify(params.body))
const iv = crypto.randomBytes(12)
const secretKey = crypto.createSecretKey(crypto.randomBytes(32))
const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, iv, { authTagLength: 16 })
const aad = new TextEncoder().encode(header)

Expand All @@ -95,7 +97,7 @@ export async function encryptRequest (params: CypressRequestOptions, publicKey?:
ciphertext: base64Url(encrypted),
recipients: [
{
encrypted_key: base64Url(crypto.publicEncrypt(key, secretKey.export())),
encrypted_key: base64Url(crypto.publicEncrypt(publicKey, secretKey.export())),
},
],
tag: base64Url(cipher.getAuthTag()),
Expand Down
2 changes: 1 addition & 1 deletion packages/server/test/unit/cloud/api/api_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const decryptReqBodyAndRespond = ({ reqBody, resBody }, fn) => {
expect(params.body).to.deep.eq(reqBody)
}

const { secretKey, jwe } = await encryptRequest(params, publicKey)
const { secretKey, jwe } = await encryptRequest(params, { publicKey })

if (fn) {
encryption.encryptRequest.restore()
Expand Down
Loading
Loading