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

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
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
78 changes: 78 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,78 @@
import type { AxiosInstance, AxiosResponse } from 'axios'
import * as enc from '../../encryption'
import { PUBLIC_KEY_VERSION } from '../../constants'
import { verifySignature } from '../../encryption'
import _ from 'lodash'
import { transformError } from './transform_error'

const verifySignatureHandler = async (res: AxiosResponse) => {
const isVerified = verifySignature(res.data, res.headers['x-cypress-signature'])

if (!isVerified) {
throw new Error(`Unable to verify the request signature for ${res.request?.path ?? 'request'}`)
}

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, encrypt: 'always' | 'signed' | true) => {
if (encrypt === 'always' || encrypt === true) {
axios.interceptors.request.use(async (req) => {
const transformResponse = _.castArray(req.transformResponse)

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

req.headers.set('x-cypress-encrypted', PUBLIC_KEY_VERSION)
req.data = jwe
transformResponse.unshift(async (res, headers) => {
if (encrypt === 'always' || headers['x-cypress-encrypted'] === 'true') {
const result = await enc.decryptResponse(JSON.parse(res), secretKey)

return result
}

return res
})

req.transformResponse = transformResponse

return req
})

axios.interceptors.response.use(async (res) => {
res.data = await res.data

// If we've sent the data back with a signature, ensure we also validate it
if (res.headers['x-cypress-signature']) {
await verifySignatureHandler(res)
}

return res
}, async (err) => {
err.response.data = await err.response.data

if (err.response.headers['x-cypress-signature']) {
await verifySignatureHandler(err.response)
}

return transformError(err)
})

axios.get = function () {
throw new Error(`Cannot issue GET requests with encryption`)
}
}

if (encrypt === 'signed') {
axios.interceptors.request.use((req) => {
req.headers.set('x-cypress-signature', PUBLIC_KEY_VERSION)

return req
})

axios.interceptors.response.use(verifySignatureHandler)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ declare module 'axios' {
}
}

export const transformError = (err: AxiosError | Error & { error?: any, statusCode: number, isApiError?: boolean }): never => {
export const transformError = (err: AxiosError | Error & { error?: any, statusCode: number, isApiError?: boolean }): never | Promise<never> => {
const { data, status } = axios.isAxiosError(err) ?
{ data: err.response?.data, status: err.status } :
{ data: err.error, status: err.statusCode }
Expand Down
57 changes: 50 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,54 @@ 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
*/
addditionalHeaders?: Record<string, string>
/**
* Whether to include e2e encryption for requests:
* true = encrypt request, decrypt response if x-cypress-encrypted signature is set
* always = encrypt request, always decrypt response
* signed = send version in x-cypress-signature, check response's x-cypress-signature
* false = no encryption
*
* @default false
*/
enableEncryption?: 'always' | 'signed' | true | false
/**
* 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, enableEncryption = false, enableLogging = true, enableErrorTransform = true } = options

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

installLogging(instance)
installErrorTransform(instance)
if (enableEncryption) {
installEncryption(instance, enableEncryption)
}

if (enableLogging) {
installLogging(instance)
}

if (enableErrorTransform) {
installErrorTransform(instance)
}

return instance
}

export const CloudRequest = _create()
export const CloudRequest = 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
2 changes: 1 addition & 1 deletion packages/server/lib/cloud/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ 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> {
export async function encryptRequest (params: Pick<CypressRequestOptions, 'body'>, publicKey?: crypto.KeyObject): Promise<EncryptRequestData> {
const key = publicKey || getPublicKey()
const header = base64Url(JSON.stringify({ alg: 'RSA-OAEP', enc: 'A256GCM', zip: 'DEF' }))
const deflated = await deflateRaw(JSON.stringify(params.body))
Expand Down
Loading
Loading