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..ee23ef073a19 --- /dev/null +++ b/packages/server/lib/cloud/api/axios_middleware/encryption.ts @@ -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 | 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) +} diff --git a/packages/server/lib/cloud/api/cloud_request.ts b/packages/server/lib/cloud/api/cloud_request.ts index 3f9fc79d5524..70412318a50b 100644 --- a/packages/server/lib/cloud/api/cloud_request.ts +++ b/packages/server/lib/cloud/api/cloud_request.ts @@ -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' + +export interface CreateCloudRequestOptions { + /** + * The baseURL for all requests for this Cloud Request instance + */ + baseURL?: string + /** + * Additional headers for the Cloud Request + */ + additionalHeaders?: Record + /** + * 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 @@ -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 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/api/cloud_request_errors.ts b/packages/server/lib/cloud/api/cloud_request_errors.ts new file mode 100644 index 000000000000..74acf37c82af --- /dev/null +++ b/packages/server/lib/cloud/api/cloud_request_errors.ts @@ -0,0 +1,8 @@ +export class DecryptionError extends Error { + isDecryptionError = true + + constructor (message: string) { + super(message) + this.name = 'DecryptionError' + } +} diff --git a/packages/server/lib/cloud/api/index.ts b/packages/server/lib/cloud/api/index.ts index ca79ed5e3339..df307ce5be41 100644 --- a/packages/server/lib/cloud/api/index.ts +++ b/packages/server/lib/cloud/api/index.ts @@ -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 diff --git a/packages/server/lib/cloud/encryption.ts b/packages/server/lib/cloud/encryption.ts index b8fc892788be..3fee7eeff6ef 100644 --- a/packages/server/lib/cloud/encryption.ts +++ b/packages/server/lib/cloud/encryption.ts @@ -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 { - const key = publicKey || getPublicKey() +export async function encryptRequest (params: Pick, options: { + publicKey?: crypto.KeyObject + secretKey?: crypto.KeyObject +} = {}): Promise { + 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()), diff --git a/packages/server/test/unit/cloud/api/api_spec.js b/packages/server/test/unit/cloud/api/api_spec.js index 3c8ff9bb2264..a16323c46a72 100644 --- a/packages/server/test/unit/cloud/api/api_spec.js +++ b/packages/server/test/unit/cloud/api/api_spec.js @@ -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() diff --git a/packages/server/test/unit/cloud/api/cloud_request_encryption_spec.ts b/packages/server/test/unit/cloud/api/cloud_request_encryption_spec.ts new file mode 100644 index 000000000000..7e60210469ab --- /dev/null +++ b/packages/server/test/unit/cloud/api/cloud_request_encryption_spec.ts @@ -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') + } + }) +}) 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..7f3f6db6eeab 100644 --- a/packages/server/test/unit/cloud/api/cloud_request_spec.ts +++ b/packages/server/test/unit/cloud/api/cloud_request_spec.ts @@ -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) 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..7a67f2167aa1 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' @@ -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) }, }) diff --git a/packages/server/test/unit/cloud/encryption_spec.js b/packages/server/test/unit/cloud/encryption_spec.js index d33b9d6f0e2b..1786362499ee 100644 --- a/packages/server/test/unit/cloud/encryption_spec.js +++ b/packages/server/test/unit/cloud/encryption_spec.js @@ -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 }