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

Draft
wants to merge 1 commit into
base: tgriesser/fix/fix-axios-proxy-networking
Choose a base branch
from
Draft
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
58 changes: 58 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,58 @@
import { AxiosInstance, AxiosResponse } from 'axios'
import * as enc from '../../encryption'
import { PUBLIC_KEY_VERSION } from '../../constants'
import { verifySignature } from '../../encryption'
import _ from 'lodash'

// 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

return res
})
}

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

return req
})

axios.interceptors.response.use(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
})
}
}
11 changes: 8 additions & 3 deletions packages/server/lib/cloud/api/cloud_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ 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 => {
// Allows us to create customized Cloud Request instances w/ different baseURL & encryption configuration
export const createCloudRequest = (options: { baseURL?: string, encrypt?: 'always' | 'signed' | true } = {}): AxiosInstance => {
const cfgKey = process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'development'

const instance = axios.create({
Expand Down Expand Up @@ -43,13 +44,17 @@ export const _create = (options: { baseURL?: string } = {}): AxiosInstance => {
},
})

if (options.encrypt) {
installEncryption(instance, options.encrypt)
}

installLogging(instance)
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
141 changes: 141 additions & 0 deletions packages/server/test/unit/cloud/api/cloud_request_encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import express 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 } from '../../../../lib/cloud/api/cloud_request'
import * as jose from 'jose'

declare global {
namespace Express {
interface Request {
unwrappedSecretKey(): crypto.KeyObject
}
}
}

describe('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()
})

app.get('/signed', async (req, res) => {
const buffer = fs.readFileSync(__filename)

if (req.headers['x-cypress-signature']) {
const sign = crypto.createSign('sha256', {
defaultEncoding: 'base64',
})

sign.update(buffer).end()
const signature = sign.sign(TEST_PRIVATE, 'base64')

res.setHeader('x-cypress-signature', signature)
}

res.write(buffer)
res.end()
})

app.get('/invalid-signing', async (req, res) => {
const hash = crypto.createHash('sha256', {
defaultEncoding: 'base64',
})
const buffer = fs.readFileSync(__filename)

hash.update(buffer).end()
res.setHeader('x-cypress-signature', hash.digest('base64'))
res.write(buffer)
res.end()
})

app.post('/', async (req, res) => {
return res.json(await encryptBody(req, res, req.body))
})

beforeEach(async () => {
requests = []
fakeEncryptionServer = await fakeServer({}, app)
})

afterEach(() => fakeEncryptionServer.teardown())

it('encrypts requests', async () => {
const EncryptReq = createCloudRequest({ baseURL: fakeEncryptionServer.baseUrl, encrypt: 'always' })

const dataObj = (v: number) => {
return {
foo: {
bar: v,
},
}
}

const [res, res2, res3] = await Promise.all([
EncryptReq.post('/', dataObj(1)),
EncryptReq.post('/', dataObj(2)),
EncryptReq.post('/', dataObj(3)),
])

expect(res.data).to.eql(dataObj(1))
expect(res2.data).to.eql(dataObj(2))
expect(res3.data).to.eql(dataObj(3))
})

it('verifies the signed response', async () => {
const SignedRes = createCloudRequest({ baseURL: fakeEncryptionServer.baseUrl, encrypt: 'signed' })

// Good
const data = await SignedRes.get('/signed').then((d) => d.data)

expect(data).to.equal(fs.readFileSync(__filename, 'utf8'))

// Bad
try {
await SignedRes.get('/invalid-signing')
throw new Error('Unreachable')
} catch (e) {
expect(e.message).to.equal('Unable to verify the request signature for /invalid-signing')
}
})
})
18 changes: 9 additions & 9 deletions packages/server/test/unit/cloud/api/cloud_request_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { createCloudRequest } from '../../../../lib/cloud/api/cloud_request'
import cloudApi from '../../../../lib/cloud/api'
import app_config from '../../../../config/app.json'
import os from 'os'
Expand All @@ -30,7 +30,7 @@ describe('CloudRequest', () => {
}

it('instantiates with network combined agent', () => {
_create()
createCloudRequest()
const cfg = getCreatedConfig()

expect(cfg.httpAgent).to.eq(agent)
Expand Down Expand Up @@ -132,7 +132,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)
}
Expand All @@ -150,14 +150,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: {
Expand Down Expand Up @@ -328,7 +328,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)
Expand Down Expand Up @@ -358,7 +358,7 @@ describe('CloudRequest', () => {

;(axios.create as sinon.SinonStub).returns(stubbedAxiosInstance)

_create()
createCloudRequest()
})

it('registers error transformation interceptor', () => {
Expand Down Expand Up @@ -388,7 +388,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)
Expand Down Expand Up @@ -416,7 +416,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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -91,7 +91,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,
Expand All @@ -111,7 +111,7 @@ export async function fakeServer (opts: FakeServerOptions) {
}
}

app(req, res)
serverApp(req, res)
},
})

Expand Down