From ce1ae93a1832ccef143c7d2723be2ef87c0bb02f Mon Sep 17 00:00:00 2001 From: Tim Griesser Date: Wed, 18 Jun 2025 21:09:02 -0400 Subject: [PATCH] chore: adding encryption to new cloud request layer --- .../cloud/api/axios_middleware/encryption.ts | 58 +++++++ .../server/lib/cloud/api/cloud_request.ts | 11 +- packages/server/lib/cloud/encryption.ts | 2 +- .../cloud/api/cloud_request_encryption.ts | 141 ++++++++++++++++++ .../test/unit/cloud/api/cloud_request_spec.ts | 18 +-- .../unit/cloud/api/utils/fake_proxy_server.ts | 6 +- 6 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 packages/server/lib/cloud/api/axios_middleware/encryption.ts create mode 100644 packages/server/test/unit/cloud/api/cloud_request_encryption.ts diff --git a/packages/server/lib/cloud/api/axios_middleware/encryption.ts b/packages/server/lib/cloud/api/axios_middleware/encryption.ts new file mode 100644 index 000000000000..c1e078c0177d --- /dev/null +++ b/packages/server/lib/cloud/api/axios_middleware/encryption.ts @@ -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 + }) + } +} diff --git a/packages/server/lib/cloud/api/cloud_request.ts b/packages/server/lib/cloud/api/cloud_request.ts index 3f9fc79d5524..c178eaf5bcd4 100644 --- a/packages/server/lib/cloud/api/cloud_request.ts +++ b/packages/server/lib/cloud/api/cloud_request.ts @@ -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({ @@ -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 diff --git a/packages/server/lib/cloud/encryption.ts b/packages/server/lib/cloud/encryption.ts index b8fc892788be..11298eb4dfc8 100644 --- a/packages/server/lib/cloud/encryption.ts +++ b/packages/server/lib/cloud/encryption.ts @@ -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 { +export async function encryptRequest (params: Pick, publicKey?: crypto.KeyObject): Promise { const key = publicKey || getPublicKey() const header = base64Url(JSON.stringify({ alg: 'RSA-OAEP', enc: 'A256GCM', zip: 'DEF' })) const deflated = await deflateRaw(JSON.stringify(params.body)) diff --git a/packages/server/test/unit/cloud/api/cloud_request_encryption.ts b/packages/server/test/unit/cloud/api/cloud_request_encryption.ts new file mode 100644 index 000000000000..660c27da307a --- /dev/null +++ b/packages/server/test/unit/cloud/api/cloud_request_encryption.ts @@ -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') + } + }) +}) diff --git a/packages/server/test/unit/cloud/api/cloud_request_spec.ts b/packages/server/test/unit/cloud/api/cloud_request_spec.ts index f55fd0eed158..e0cde47f6896 100644 --- a/packages/server/test/unit/cloud/api/cloud_request_spec.ts +++ b/packages/server/test/unit/cloud/api/cloud_request_spec.ts @@ -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' @@ -30,7 +30,7 @@ describe('CloudRequest', () => { } it('instantiates with network combined agent', () => { - _create() + createCloudRequest() const cfg = getCreatedConfig() expect(cfg.httpAgent).to.eq(agent) @@ -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) } @@ -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: { @@ -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) @@ -358,7 +358,7 @@ describe('CloudRequest', () => { ;(axios.create as sinon.SinonStub).returns(stubbedAxiosInstance) - _create() + createCloudRequest() }) it('registers error transformation interceptor', () => { @@ -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) @@ -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) diff --git a/packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts b/packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts index 0f07447693c5..62a4d8ff50d5 100644 --- a/packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts +++ b/packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts @@ -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' @@ -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, @@ -111,7 +111,7 @@ export async function fakeServer (opts: FakeServerOptions) { } } - app(req, res) + serverApp(req, res) }, })