chore: continuing with request to axios changes (#31915)

This commit is contained in:
Tim Griesser
2025-06-27 17:18:12 -04:00
committed by GitHub
parent 4dd4e35378
commit ed8e7ea8a4
10 changed files with 489 additions and 35 deletions

View File

@@ -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)
}

View File

@@ -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'
@@ -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'
// initialized with an export for testing purposes
export const _create = (options: { baseURL?: string } = {}): AxiosInstance => {
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
}
// 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
@@ -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

View File

@@ -0,0 +1,8 @@
export class DecryptionError extends Error {
isDecryptionError = true
constructor (message: string) {
super(message)
this.name = 'DecryptionError'
}
}

View File

@@ -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')
@@ -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

View File

@@ -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)
@@ -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()),

View File

@@ -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()

View File

@@ -0,0 +1,230 @@
import express, { Request, Response } from 'express'
import crypto from 'crypto'
import { expect } from 'chai'
import fs from 'fs'
import { DestroyableProxy, fakeServer } from './utils/fake_proxy_server'
import bodyParser from 'body-parser'
import { TEST_PRIVATE } from '@tooling/system-tests/lib/protocol-stubs/protocolStubResponse'
import { createCloudRequest, TCloudReqest } from '../../../../lib/cloud/api/cloud_request'
import * as jose from 'jose'
import dedent from 'dedent'
declare global {
namespace Express {
interface Request {
unwrappedSecretKey(): crypto.KeyObject
}
}
}
describe('CloudRequest Encryption', () => {
let fakeEncryptionServer: DestroyableProxy
const app = express()
let requests: express.Request[] = []
const encryptBody = async (req: express.Request, res: express.Response, body: object) => {
const enc = new jose.GeneralEncrypt(Buffer.from(JSON.stringify(body)))
enc
.setProtectedHeader({ alg: 'A256GCMKW', enc: 'A256GCM', zip: 'DEF' })
.addRecipient(req.unwrappedSecretKey())
res.header('x-cypress-encrypted', 'true')
return await enc.encrypt()
}
app.use(bodyParser.json())
app.use((req, res, next) => {
requests.push(req)
if (req.headers['x-cypress-encrypted']) {
const jwe = req.body
req.unwrappedSecretKey = () => {
return crypto.createSecretKey(
crypto.privateDecrypt(
TEST_PRIVATE,
Buffer.from(jwe.recipients[0].encrypted_key, 'base64url'),
),
)
}
return jose.generalDecrypt(jwe, TEST_PRIVATE).then(({ plaintext }) => Buffer.from(plaintext).toString('utf8')).then((body) => {
req.body = JSON.parse(body)
next()
}).catch(next)
}
next()
})
function signResponse (req: Request, res: Response, val: Buffer | string) {
if (req.headers['x-cypress-signature']) {
const sign = crypto.createSign('sha256', {
defaultEncoding: 'base64',
})
sign.update(val).end()
const signature = sign.sign(TEST_PRIVATE, 'base64')
res.setHeader('x-cypress-signature', signature)
}
res.write(val)
res.end()
}
function invalidSignResponse (req: Request, res: Response, val: Buffer | string) {
const hash = crypto.createHash('sha256', {
defaultEncoding: 'base64',
})
hash.update(val).end()
res.setHeader('x-cypress-signature', hash.digest('base64'))
res.write(val)
res.end()
}
app.get('/ping', (req, res) => res.json({ pong: 'true' }))
app.get('/signed', async (req, res) => {
const buffer = fs.readFileSync(__filename)
return signResponse(req, res, buffer)
})
app.get('/invalid-signing', async (req, res) => {
const buffer = fs.readFileSync(__filename)
return invalidSignResponse(req, res, buffer)
})
app.post('/signed-post', async (req, res) => {
return signResponse(req, res, JSON.stringify(req.body))
})
app.post('/invalid-signed-post', async (req, res) => {
return invalidSignResponse(req, res, JSON.stringify(req.body))
})
app.post('/', async (req, res) => {
return res.json(await encryptBody(req, res, req.body))
})
app.post('/error', async (req, res) => {
return res.status(400).json(await encryptBody(req, res, {
error: 'Some Error',
}))
})
let TestReq: TCloudReqest
before(async () => {
fakeEncryptionServer = await fakeServer({}, app)
TestReq = createCloudRequest({ baseURL: fakeEncryptionServer.baseUrl })
})
beforeEach(async () => {
requests = []
})
after(() => fakeEncryptionServer.teardown())
it('cannot issue encryption request without body', async () => {
try {
await TestReq.get('/foo', {
encrypt: true,
})
throw new Error('Unreachable')
} catch (e) {
expect(e.message).to.eq('Cannot issue encrypted request to /foo without request body')
}
})
it('verifies the signed response', async () => {
// Good
const data = await TestReq.get('/signed', { encrypt: 'signed' }).then((d) => d.data)
expect(data).to.equal(fs.readFileSync(__filename, 'utf8'))
// Bad
try {
await TestReq.get('/invalid-signing', { encrypt: 'signed' })
throw new Error('Unreachable')
} catch (e) {
expect(e.message).to.equal('Unable to verify response signature for /invalid-signing')
}
})
it('enforces a response signature on signed requests', async () => {
try {
await TestReq.get('/ping', { encrypt: 'signed' })
throw new Error('Unreachable')
} catch (e) {
expect(e.message).to.equal('Expected signed response for /ping')
}
})
it('encrypts requests', async () => {
const dataObj = (v: number) => {
return {
foo: {
bar: v,
},
}
}
const [res, res2, res3] = await Promise.all([
TestReq.post('/', dataObj(1), { encrypt: 'always' }),
TestReq.post('/', dataObj(2), { encrypt: 'always' }),
TestReq.post('/', dataObj(3), { encrypt: 'always' }),
])
expect(res.data).to.eql(dataObj(1))
expect(res2.data).to.eql(dataObj(2))
expect(res3.data).to.eql(dataObj(3))
})
it('decrypts errors', async () => {
try {
await TestReq.post('/error', {
foo: true,
}, { encrypt: 'always' })
throw new Error('Unreachable')
} catch (e) {
expect(e.isApiError).to.be.true
expect(e.message).to.equal(dedent`
400
{
"error": "Some Error"
}
`)
}
})
it('supports a signed response on encrypted requests', async () => {
// Good
const data = await TestReq.post('/signed-post', {
foo: 'bar',
}, { encrypt: 'signed' }).then((d) => d.data)
expect(data).to.eql({ foo: 'bar' })
// Bad
try {
await TestReq.post('/invalid-signed-post', {}, {
encrypt: 'signed',
})
throw new Error('Unreachable')
} catch (e) {
expect(e.message).to.equal('Unable to verify response signature for /invalid-signed-post')
}
})
})

View File

@@ -4,13 +4,16 @@ import sinonChai from 'sinon-chai'
import chai, { expect } from 'chai'
import agent from '@packages/network/lib/agent'
import axios, { CreateAxiosDefaults, AxiosInstance } from 'axios'
import { _create } from '../../../../lib/cloud/api/cloud_request'
import debugLib from 'debug'
import stripAnsi from 'strip-ansi'
import { createCloudRequest } from '../../../../lib/cloud/api/cloud_request'
import cloudApi from '../../../../lib/cloud/api'
import app_config from '../../../../config/app.json'
import os from 'os'
import pkg from '@packages/root'
import { transformError } from '../../../../lib/cloud/api/axios_middleware/transform_error'
import { DestroyableProxy, fakeServer, fakeProxy } from './utils/fake_proxy_server'
import dedent from 'dedent'
chai.use(sinonChai)
@@ -30,7 +33,7 @@ describe('CloudRequest', () => {
}
it('instantiates with network combined agent', () => {
_create()
createCloudRequest()
const cfg = getCreatedConfig()
expect(cfg.httpAgent).to.eq(agent)
@@ -132,7 +135,7 @@ describe('CloudRequest', () => {
}
if (adapter === 'Axios') {
const CloudReq = _create({ baseURL: targetServer.baseUrl })
const CloudReq = createCloudRequest({ baseURL: targetServer.baseUrl })
return CloudReq[method](`/ping`, {}).then((r) => r.data)
}
@@ -150,14 +153,14 @@ describe('CloudRequest', () => {
}
it('does a basic request', async () => {
const CloudReq = _create({ baseURL: fakeHttpUpstream.baseUrl })
const CloudReq = createCloudRequest({ baseURL: fakeHttpUpstream.baseUrl })
expect(await CloudReq.get('/ping').then((r) => r.data)).to.eql('OK')
expect(fakeHttpUpstream.requests[0].rawHeaders).to.not.contain('Proxy-Authorization')
})
it('retains Proxy-Authorization for non-proxied requests', async () => {
const CloudReq = _create({ baseURL: fakeHttpUpstream.baseUrl })
const CloudReq = createCloudRequest({ baseURL: fakeHttpUpstream.baseUrl })
expect(await CloudReq.get('/ping', {
headers: {
@@ -310,6 +313,81 @@ describe('CloudRequest', () => {
}
})
describe('createCloudRequest', () => {
let fakeApp: DestroyableProxy
before(async () => {
fakeApp = await fakeServer({})
})
after(() => fakeApp.teardown())
let wasEnabled: string
beforeEach(() => {
wasEnabled = debugLib.disable()
})
afterEach(() => {
debugLib.enable(wasEnabled)
sinon.restore()
})
it('can skip installing logging', async () => {
debugLib.enable('cypress:server:cloud:api')
const CloudRequest = createCloudRequest({ baseURL: fakeApp.baseUrl })
const logSpy = sinon.stub(process.stderr, 'write')
await CloudRequest.get('/ping')
const debugCalls = logSpy.getCalls().flatMap((c) => stripAnsi(String(c.args[0])).trim().replace(/\+(\d+)ms$/, '+?ms'))
expect(debugCalls).to.eql([
'cypress:server:cloud:api get /ping +?ms',
'cypress:server:cloud:api get /ping Success: 200 OK -> \n cypress:server:cloud:api Response: \'OK\' +?ms',
])
logSpy.reset()
const CloudRequestNoLogs = createCloudRequest({ baseURL: fakeApp.baseUrl, enableLogging: false })
await CloudRequestNoLogs.get('/ping')
expect(logSpy.getCalls()).to.eql([])
})
it('can skip installing the error transform', async () => {
const CloudRequest = createCloudRequest({ baseURL: fakeApp.baseUrl })
// Installed
try {
await CloudRequest.get('/error')
throw new Error('Unreachable')
} catch (e) {
expect(e.isApiError).to.eql(true)
expect(e.message).to.equal(dedent`
404
{
"ok": false
}
`)
}
const CloudRequestNoError = createCloudRequest({ baseURL: fakeApp.baseUrl, enableErrorTransform: false })
// Not Installed
try {
await CloudRequestNoError.get('/error')
throw new Error('Unreachable')
} catch (e) {
expect(e.isApiError).to.eql(undefined)
expect(e.response.data).to.eql({ ok: false })
}
})
})
describe('headers', () => {
const platform = 'sunos'
const version = '0.0.0'
@@ -328,7 +406,7 @@ describe('CloudRequest', () => {
})
it('sets exepcted platform, version, and user-agent headers', () => {
_create()
createCloudRequest()
const cfg = getCreatedConfig()
expect(cfg.headers).to.have.property('x-os-name', platform)
@@ -358,7 +436,7 @@ describe('CloudRequest', () => {
;(axios.create as sinon.SinonStub).returns(stubbedAxiosInstance)
_create()
createCloudRequest()
})
it('registers error transformation interceptor', () => {
@@ -388,7 +466,7 @@ describe('CloudRequest', () => {
})
it('sets to the value defined in app config', () => {
_create()
createCloudRequest()
const cfg = getCreatedConfig()
expect(cfg.baseURL).to.eq(app_config[env ?? 'development']?.api_url)
@@ -416,7 +494,7 @@ describe('CloudRequest', () => {
})
it('sets to the value defined in app config', () => {
_create()
createCloudRequest()
const cfg = getCreatedConfig()
expect(cfg.baseURL).to.eq(app_config[env ?? 'development']?.api_url)

View File

@@ -1,7 +1,7 @@
/* eslint-disable no-console */
import http from 'http'
import { AddressInfo } from 'net'
import express from 'express'
import express, { Application } from 'express'
import Promise from 'bluebird'
import debugLib from 'debug'
import DebuggingProxy from '@cypress/debugging-proxy'
@@ -23,6 +23,10 @@ app.post('/ping', (req, res) => {
res.json({ ok: true, auth: req.headers['authorization'] })
})
app.get('/error', (req, res) => {
res.status(404).json({ ok: false })
})
interface DestroyableProxyOptions {
keepRequests?: boolean
auth?: {
@@ -91,7 +95,7 @@ interface FakeProxyOptions {
}
}
export async function fakeServer (opts: FakeServerOptions) {
export async function fakeServer (opts: FakeServerOptions, serverApp: Application = app) {
const port = await getPort()
const server = new DestroyableProxy({
auth: opts.auth,
@@ -111,7 +115,7 @@ export async function fakeServer (opts: FakeServerOptions) {
}
}
app(req, res)
serverApp(req, res)
},
})

View File

@@ -30,7 +30,7 @@ describe('encryption', () => {
const { jwe, secretKey } = await encryption.encryptRequest({
encrypt: true,
body: TEST_BODY,
}, publicKey)
}, { publicKey })
const { plaintext } = await jose.generalDecrypt(jwe, privateKey)
@@ -47,7 +47,7 @@ describe('encryption', () => {
const { jwe, secretKey } = await encryption.encryptRequest({
encrypt: true,
body: TEST_BODY,
}, publicKey)
}, { publicKey })
const RESPONSE_BODY = { runId: 123 }