Skip to content

Commit b634cc5

Browse files
committed
chore: adding encryption to new cloud request layer
1 parent 4712750 commit b634cc5

File tree

6 files changed

+220
-16
lines changed

6 files changed

+220
-16
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { AxiosInstance, AxiosResponse } from 'axios'
2+
import * as enc from '../../encryption'
3+
import { PUBLIC_KEY_VERSION } from '../../constants'
4+
import { verifySignature } from '../../encryption'
5+
import _ from 'lodash'
6+
7+
// Always = req & res MUST be encrypted
8+
// true = req MUST be encrypted, res MAY be encrypted, signified by header
9+
// signed = verify signature of the response body
10+
export const installEncryption = (axios: AxiosInstance, encrypt: 'always' | 'signed' | true) => {
11+
if (encrypt === 'always' || encrypt === true) {
12+
axios.interceptors.request.use(async (req) => {
13+
const transformResponse = _.castArray(req.transformResponse)
14+
15+
const { jwe, secretKey } = await enc.encryptRequest({ body: req.data })
16+
17+
req.headers.set('x-cypress-encrypted', PUBLIC_KEY_VERSION)
18+
req.data = jwe
19+
transformResponse.unshift(async (res, headers) => {
20+
if (encrypt === 'always' || headers['x-cypress-encrypted'] === 'true') {
21+
const result = await enc.decryptResponse(JSON.parse(res), secretKey)
22+
23+
return result
24+
}
25+
26+
return res
27+
})
28+
29+
req.transformResponse = transformResponse
30+
31+
return req
32+
})
33+
34+
axios.interceptors.response.use(async (res) => {
35+
res.data = await res.data
36+
37+
return res
38+
})
39+
}
40+
41+
if (encrypt === 'signed') {
42+
axios.interceptors.request.use((req) => {
43+
req.headers.set('x-cypress-signature', PUBLIC_KEY_VERSION)
44+
45+
return req
46+
})
47+
48+
axios.interceptors.response.use(async (res: AxiosResponse) => {
49+
const isVerified = verifySignature(res.data, res.headers['x-cypress-signature'])
50+
51+
if (!isVerified) {
52+
throw new Error(`Unable to verify the request signature for ${res.request?.path ?? 'request'}`)
53+
}
54+
55+
return res
56+
})
57+
}
58+
}

packages/server/lib/cloud/api/cloud_request.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import agent from '@packages/network/lib/agent'
1111
import app_config from '../../../config/app.json'
1212
import { installErrorTransform } from './axios_middleware/transform_error'
1313
import { installLogging } from './axios_middleware/logging'
14+
import { installEncryption } from './axios_middleware/encryption'
1415

15-
// initialized with an export for testing purposes
16-
export const _create = (options: { baseURL?: string } = {}): AxiosInstance => {
16+
// Allows us to create customized Cloud Request instances w/ different baseURL & encryption configuration
17+
export const createCloudRequest = (options: { baseURL?: string, encrypt?: 'always' | 'signed' | true } = {}): AxiosInstance => {
1718
const cfgKey = process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'development'
1819

1920
const instance = axios.create({
@@ -43,13 +44,17 @@ export const _create = (options: { baseURL?: string } = {}): AxiosInstance => {
4344
},
4445
})
4546

47+
if (options.encrypt) {
48+
installEncryption(instance, options.encrypt)
49+
}
50+
4651
installLogging(instance)
4752
installErrorTransform(instance)
4853

4954
return instance
5055
}
5156

52-
export const CloudRequest = _create()
57+
export const CloudRequest = createCloudRequest()
5358

5459
export const isRetryableCloudError = (error: unknown) => {
5560
// setting this env via mocha's beforeEach coerces this to a string, even if it's a boolean

packages/server/lib/cloud/encryption.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function verifySignatureFromFile (file: string, signature: string, public
6969
// in the jose library (https://github.com/panva/jose/blob/main/src/jwe/general/encrypt.ts),
7070
// but allows us to keep track of the encrypting key locally, to optionally use it for decryption
7171
// of encrypted payloads coming back in the response body.
72-
export async function encryptRequest (params: CypressRequestOptions, publicKey?: crypto.KeyObject): Promise<EncryptRequestData> {
72+
export async function encryptRequest (params: Pick<CypressRequestOptions, 'body'>, publicKey?: crypto.KeyObject): Promise<EncryptRequestData> {
7373
const key = publicKey || getPublicKey()
7474
const header = base64Url(JSON.stringify({ alg: 'RSA-OAEP', enc: 'A256GCM', zip: 'DEF' }))
7575
const deflated = await deflateRaw(JSON.stringify(params.body))
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import express from 'express'
2+
import crypto from 'crypto'
3+
import { expect } from 'chai'
4+
import fs from 'fs'
5+
6+
import { DestroyableProxy, fakeServer } from './utils/fake_proxy_server'
7+
import bodyParser from 'body-parser'
8+
import { TEST_PRIVATE } from '@tooling/system-tests/lib/protocol-stubs/protocolStubResponse'
9+
import { createCloudRequest } from '../../../../lib/cloud/api/cloud_request'
10+
import * as jose from 'jose'
11+
12+
declare global {
13+
namespace Express {
14+
interface Request {
15+
unwrappedSecretKey(): crypto.KeyObject
16+
}
17+
}
18+
}
19+
20+
describe('Encryption', () => {
21+
let fakeEncryptionServer: DestroyableProxy
22+
const app = express()
23+
24+
let requests: express.Request[] = []
25+
26+
const encryptBody = async (req: express.Request, res: express.Response, body: object) => {
27+
const enc = new jose.GeneralEncrypt(Buffer.from(JSON.stringify(body)))
28+
29+
enc
30+
.setProtectedHeader({ alg: 'A256GCMKW', enc: 'A256GCM', zip: 'DEF' })
31+
.addRecipient(req.unwrappedSecretKey())
32+
33+
res.header('x-cypress-encrypted', 'true')
34+
35+
return await enc.encrypt()
36+
}
37+
38+
app.use(bodyParser.json())
39+
app.use((req, res, next) => {
40+
requests.push(req)
41+
if (req.headers['x-cypress-encrypted']) {
42+
const jwe = req.body
43+
44+
req.unwrappedSecretKey = () => {
45+
return crypto.createSecretKey(
46+
crypto.privateDecrypt(
47+
TEST_PRIVATE,
48+
Buffer.from(jwe.recipients[0].encrypted_key, 'base64url'),
49+
),
50+
)
51+
}
52+
53+
return jose.generalDecrypt(jwe, TEST_PRIVATE).then(({ plaintext }) => Buffer.from(plaintext).toString('utf8')).then((body) => {
54+
req.body = JSON.parse(body)
55+
next()
56+
}).catch(next)
57+
}
58+
59+
next()
60+
})
61+
62+
app.get('/signed', async (req, res) => {
63+
const buffer = fs.readFileSync(__filename)
64+
65+
if (req.headers['x-cypress-signature']) {
66+
const sign = crypto.createSign('sha256', {
67+
defaultEncoding: 'base64',
68+
})
69+
70+
sign.update(buffer).end()
71+
const signature = sign.sign(TEST_PRIVATE, 'base64')
72+
73+
res.setHeader('x-cypress-signature', signature)
74+
}
75+
76+
res.write(buffer)
77+
res.end()
78+
})
79+
80+
app.get('/invalid-signing', async (req, res) => {
81+
const hash = crypto.createHash('sha256', {
82+
defaultEncoding: 'base64',
83+
})
84+
const buffer = fs.readFileSync(__filename)
85+
86+
hash.update(buffer).end()
87+
res.setHeader('x-cypress-signature', hash.digest('base64'))
88+
res.write(buffer)
89+
res.end()
90+
})
91+
92+
app.post('/', async (req, res) => {
93+
return res.json(await encryptBody(req, res, req.body))
94+
})
95+
96+
beforeEach(async () => {
97+
requests = []
98+
fakeEncryptionServer = await fakeServer({}, app)
99+
})
100+
101+
afterEach(() => fakeEncryptionServer.teardown())
102+
103+
it('encrypts requests', async () => {
104+
const EncryptReq = createCloudRequest({ baseURL: fakeEncryptionServer.baseUrl, encrypt: 'always' })
105+
106+
const dataObj = (v: number) => {
107+
return {
108+
foo: {
109+
bar: v,
110+
},
111+
}
112+
}
113+
114+
const [res, res2, res3] = await Promise.all([
115+
EncryptReq.post('/', dataObj(1)),
116+
EncryptReq.post('/', dataObj(2)),
117+
EncryptReq.post('/', dataObj(3)),
118+
])
119+
120+
expect(res.data).to.eql(dataObj(1))
121+
expect(res2.data).to.eql(dataObj(2))
122+
expect(res3.data).to.eql(dataObj(3))
123+
})
124+
125+
it('verifies the signed response', async () => {
126+
const SignedRes = createCloudRequest({ baseURL: fakeEncryptionServer.baseUrl, encrypt: 'signed' })
127+
128+
// Good
129+
const data = await SignedRes.get('/signed').then((d) => d.data)
130+
131+
expect(data).to.equal(fs.readFileSync(__filename, 'utf8'))
132+
133+
// Bad
134+
try {
135+
await SignedRes.get('/invalid-signing')
136+
throw new Error('Unreachable')
137+
} catch (e) {
138+
expect(e.message).to.equal('Unable to verify the request signature for /invalid-signing')
139+
}
140+
})
141+
})

packages/server/test/unit/cloud/api/cloud_request_spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import sinonChai from 'sinon-chai'
44
import chai, { expect } from 'chai'
55
import agent from '@packages/network/lib/agent'
66
import axios, { CreateAxiosDefaults, AxiosInstance } from 'axios'
7-
import { _create } from '../../../../lib/cloud/api/cloud_request'
7+
import { createCloudRequest } from '../../../../lib/cloud/api/cloud_request'
88
import cloudApi from '../../../../lib/cloud/api'
99
import app_config from '../../../../config/app.json'
1010
import os from 'os'
@@ -30,7 +30,7 @@ describe('CloudRequest', () => {
3030
}
3131

3232
it('instantiates with network combined agent', () => {
33-
_create()
33+
createCloudRequest()
3434
const cfg = getCreatedConfig()
3535

3636
expect(cfg.httpAgent).to.eq(agent)
@@ -132,7 +132,7 @@ describe('CloudRequest', () => {
132132
}
133133

134134
if (adapter === 'Axios') {
135-
const CloudReq = _create({ baseURL: targetServer.baseUrl })
135+
const CloudReq = createCloudRequest({ baseURL: targetServer.baseUrl })
136136

137137
return CloudReq[method](`/ping`, {}).then((r) => r.data)
138138
}
@@ -150,14 +150,14 @@ describe('CloudRequest', () => {
150150
}
151151

152152
it('does a basic request', async () => {
153-
const CloudReq = _create({ baseURL: fakeHttpUpstream.baseUrl })
153+
const CloudReq = createCloudRequest({ baseURL: fakeHttpUpstream.baseUrl })
154154

155155
expect(await CloudReq.get('/ping').then((r) => r.data)).to.eql('OK')
156156
expect(fakeHttpUpstream.requests[0].rawHeaders).to.not.contain('Proxy-Authorization')
157157
})
158158

159159
it('retains Proxy-Authorization for non-proxied requests', async () => {
160-
const CloudReq = _create({ baseURL: fakeHttpUpstream.baseUrl })
160+
const CloudReq = createCloudRequest({ baseURL: fakeHttpUpstream.baseUrl })
161161

162162
expect(await CloudReq.get('/ping', {
163163
headers: {
@@ -328,7 +328,7 @@ describe('CloudRequest', () => {
328328
})
329329

330330
it('sets exepcted platform, version, and user-agent headers', () => {
331-
_create()
331+
createCloudRequest()
332332
const cfg = getCreatedConfig()
333333

334334
expect(cfg.headers).to.have.property('x-os-name', platform)
@@ -358,7 +358,7 @@ describe('CloudRequest', () => {
358358

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

361-
_create()
361+
createCloudRequest()
362362
})
363363

364364
it('registers error transformation interceptor', () => {
@@ -388,7 +388,7 @@ describe('CloudRequest', () => {
388388
})
389389

390390
it('sets to the value defined in app config', () => {
391-
_create()
391+
createCloudRequest()
392392
const cfg = getCreatedConfig()
393393

394394
expect(cfg.baseURL).to.eq(app_config[env ?? 'development']?.api_url)
@@ -416,7 +416,7 @@ describe('CloudRequest', () => {
416416
})
417417

418418
it('sets to the value defined in app config', () => {
419-
_create()
419+
createCloudRequest()
420420
const cfg = getCreatedConfig()
421421

422422
expect(cfg.baseURL).to.eq(app_config[env ?? 'development']?.api_url)

packages/server/test/unit/cloud/api/utils/fake_proxy_server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable no-console */
22
import http from 'http'
33
import { AddressInfo } from 'net'
4-
import express from 'express'
4+
import express, { Application } from 'express'
55
import Promise from 'bluebird'
66
import debugLib from 'debug'
77
import DebuggingProxy from '@cypress/debugging-proxy'
@@ -91,7 +91,7 @@ interface FakeProxyOptions {
9191
}
9292
}
9393

94-
export async function fakeServer (opts: FakeServerOptions) {
94+
export async function fakeServer (opts: FakeServerOptions, serverApp: Application = app) {
9595
const port = await getPort()
9696
const server = new DestroyableProxy({
9797
auth: opts.auth,
@@ -111,7 +111,7 @@ export async function fakeServer (opts: FakeServerOptions) {
111111
}
112112
}
113113

114-
app(req, res)
114+
serverApp(req, res)
115115
},
116116
})
117117

0 commit comments

Comments
 (0)