Skip to content

Commit

Permalink
feat: add polling for outbound relay configuration (#100)
Browse files Browse the repository at this point in the history
  • Loading branch information
boilsquid authored Oct 26, 2022
1 parent cadd0c2 commit fb2e830
Show file tree
Hide file tree
Showing 12 changed files with 413 additions and 113 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,13 @@ Options to control how your Cage is run
| async | Boolean | false | Run your Cage in async mode. Async Cage runs will be queued for processing. |
| version | Number | undefined | Specify the version of your Cage to run. By default, the latest version will be run. |

### Enable outbound interception for specific domains
### Enable Outbound Relay for specific domains

You may pass in an array of domains which you **do** want to be intercepted, i.e. requests sent to these domains will be intercepted, and hence will be decrypted. This array is passed in the `decryptionDomains` option. Wildcards domains are supported.
Outbound Relay will decrypt any Evervault encrypted data sent to a domain that is configured as an Outbound Relay Destination in the [UI](https://app.evervault.com). Setting the `enableOutboundRelay` option to `true` will enable and sync your Outbound Relay destinations with the SDK.

```javascript
const evervaultClient = new Evervault('<API-KEY>', {
decryptionDomains: ['httpbin.org', 'api.acme.com', '*.acme.com'], // requests to these domains will be sent through Relay
enableOutboundRelay: true
});
```

Expand Down
2 changes: 2 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const DEFAULT_API_URL = 'https://api.evervault.com';
const DEFAULT_CAGE_RUN_URL = 'https://run.evervault.com';
const DEFAULT_TUNNEL_HOSTNAME = 'https://relay.evervault.com:443';
const DEFAULT_CA_HOSTNAME = 'https://ca.evervault.com';
const DEFAULT_POLL_INTERVAL = 120;

module.exports = (apikey) => ({
http: {
Expand All @@ -16,6 +17,7 @@ module.exports = (apikey) => ({
responseType: 'json',
tunnelHostname: process.env.EV_TUNNEL_HOSTNAME || DEFAULT_TUNNEL_HOSTNAME,
certHostname: process.env.EV_CERT_HOSTNAME || DEFAULT_CA_HOSTNAME,
pollInterval: process.env.EV_POLL_INTERVAL || DEFAULT_POLL_INTERVAL,
},
encryption: {
secp256k1: {
Expand Down
25 changes: 24 additions & 1 deletion lib/core/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ module.exports = (config) => {
return response.body;
};

const getRelayOutboundConfig = async (debugRequests) => {
if (debugRequests) {
console.log(
'EVERVAULT DEBUG :: Polling Evervault API for outbound config'
);
}
const response = await get('v2/relay-outbound').catch((e) => {
throw new errors.RelayOutboundConfigError(
`An error occoured while retrieving the Relay Outbound configuration: ${e}`
);
});
if (response.statusCode >= 200 && response.statusCode < 300) {
return response.body;
}
throw errors.mapApiResponseToError(response);
};

const buildRunHeaders = ({ version, async }) => {
const headers = {};
if (version) {
Expand Down Expand Up @@ -86,5 +103,11 @@ module.exports = (config) => {
);
};

return { getCageKey, runCage, getCert, createRunToken };
return {
getCageKey,
runCage,
getCert,
createRunToken,
getRelayOutboundConfig,
};
};
152 changes: 64 additions & 88 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
const crypto = require('crypto');
const tls = require('tls');
const https = require('https');
const retry = require('async-retry');
const { Buffer } = require('buffer');

const HttpsProxyAgent = require('./utils/proxyAgent');
const {
Datatypes,
errors,
sourceParser,
cageLock,
deploy,
environment,
certHelper,
validationHelper,
httpsHelper,
polling,
} = require('./utils');
const Config = require('./config');
const { Crypto, Http } = require('./core');

const origCreateSecureContext = tls.createSecureContext;
const originalRequest = https.request;
const domainRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;

class EvervaultClient {
static CURVES = {
Expand Down Expand Up @@ -54,29 +50,62 @@ class EvervaultClient {
this.retry = options.retry;
this.http = Http(this.config.http);
this.crypto = Crypto(this.config.encryption[curve], this.http);
this.httpsHelper = httpsHelper;
this.pollInterval = this.config.http.pollInterval * 1000;
this.polling = polling;

this.defineHiddenProperty(
'_ecdh',
crypto.createECDH(this.config.encryption[curve].ecdhCurve)
);

if (options.intercept || options.ignoreDomains) {
this._shouldOverloadHttpModule(options, apiKey);
}

async _shouldOverloadHttpModule(options, apiKey) {
if (
options.intercept ||
options.ignoreDomains ||
options.decryptionDomains
) {
console.warn(
'\x1b[43m\x1b[30mWARN\x1b[0m The `intercept` and `ignoreDomains` config options in Evervault Node.js SDK are deprecated and slated for removal.',
'\n\x1b[43m\x1b[30mWARN\x1b[0m Please switch to the `decryptionDomains` config option.',
'\n\x1b[43m\x1b[30mWARN\x1b[0m More details: https://docs.evervault.com/reference/nodejs-sdk#evervaultsdk'
);
} else if (options.intercept !== false && options.enableOutboundRelay) {
// ^ preserves backwards compatibility with if relay is explictly turned off
this.intervalId = await this.polling.pollRelayOutboundConfig(
this.pollInterval,
async () => {
try {
const configResponse = await this.http.getRelayOutboundConfig(
Boolean(options.debugRequests)
);
this.relayOutboundConfig = Object.values(
configResponse.outboundDestinations
).map((config) => config.destinationDomain);
} catch (_e) {
console.error(
'EVERVAULT :: An error occurred while attempting to refresh the outbound relay config'
);
}
},
Boolean(options.debugRequests)
);
}

if (options.decryptionDomains && options.decryptionDomains.length > 0) {
const decryptionDomainsFilter = this._decryptionDomainsFilter(
options.decryptionDomains
);
this._overloadHttpsModule(
await this.httpsHelper.overloadHttpsModule(
apiKey,
this.config.http.tunnelHostname,
decryptionDomainsFilter,
Boolean(options.debugRequests)
Boolean(options.debugRequests),
this.http,
originalRequest
);
} else if (
options.intercept === true ||
Expand All @@ -86,94 +115,31 @@ class EvervaultClient {
const ignoreDomainsFilter = this._ignoreDomainFilter(
options.ignoreDomains
);
this._overloadHttpsModule(
await this.httpsHelper.overloadHttpsModule(
apiKey,
this.config.http.tunnelHostname,
ignoreDomainsFilter,
Boolean(options.debugRequests)
Boolean(options.debugRequests),
this.http,
originalRequest
);
} else if (
this.relayOutboundConfig &&
Object.keys(this.relayOutboundConfig).length > 0
) {
await this.httpsHelper.overloadHttpsModule(
apiKey,
this.config.http.tunnelHostname,
this._relayOutboundConfigDomainFilter(),
Boolean(options.debugRequests),
this.http,
originalRequest
);
} else {
https.request = originalRequest;
}
}

/**
* @param {String} apiKey
* @returns {void}
*/
async _overloadHttpsModule(
apiKey,
tunnelHostname,
domainFilter,
debugRequests = false
) {
let x509 = null;
let evClient = this;
async function updateCertificate() {
const pem = await evClient.http.getCert();
let cert = pem.toString();
x509 = certHelper.parseX509(cert);
tls.createSecureContext = (options) => {
const context = origCreateSecureContext(options);
context.context.addCACert(pem);
return context;
};
}

function isCertificateInvalid() {
if (!Datatypes.isDefined(x509)) {
return true;
}
const epoch = new Date().valueOf();
return (
epoch > new Date(x509.validTo).valueOf() ||
epoch < new Date(x509.validFrom).valueOf()
);
}

function getDomainFromArgs(args) {
if (typeof args[0] === 'string') {
return new URL(args[0]).host;
}

if (args.url) {
return args.url.match(domainRegex)[0];
}

let domain;
for (const arg of args) {
if (arg instanceof Object) {
domain = domain || arg.hostname || arg.host;
}
}
return domain;
}

function wrapMethodRequest(...args) {
const domain = getDomainFromArgs(args);
const shouldProxy = domainFilter(domain);
if (debugRequests) {
console.log(
`EVERVAULT DEBUG :: Request to domain: ${domain}, Outbound Proxy enabled: ${shouldProxy}`
);
}
args = args.map((arg) => {
if (shouldProxy && arg instanceof Object) {
arg.agent = new HttpsProxyAgent(
tunnelHostname,
updateCertificate,
isCertificateInvalid
);
arg.headers = { ...arg.headers, 'Proxy-Authorization': apiKey };
}
return arg;
});
return originalRequest.apply(this, args);
}

https.request = wrapMethodRequest;
}

_alwaysIgnoreDomains() {
const cagesHost = new URL(this.config.http.cageRunUrl).host;
const caHost = new URL(this.config.http.certHostname).host;
Expand Down Expand Up @@ -225,6 +191,16 @@ class EvervaultClient {
!this._isIgnoreRequest(domain, ignoreExact, ignoreEndsWith);
}

_relayOutboundConfigDomainFilter() {
return (domain) => {
return this._isDecryptionDomain(
domain,
this.relayOutboundConfig,
this._alwaysIgnoreDomains()
);
};
}

_isIgnoreRequest(domain, ignoreExact, ignoreEndsWith) {
return this._exactOrEndsWith(domain, ignoreExact, ignoreEndsWith);
}
Expand Down
3 changes: 3 additions & 0 deletions lib/utils/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class ForbiddenIPError extends EvervaultError {}

class DecryptError extends EvervaultError {}

class RelayOutboundConfigError extends EvervaultError {}

const mapApiResponseToError = ({ statusCode, body, headers }) => {
if (statusCode === 401) return new ApiKeyError('Invalid Api Key provided.');
if (
Expand Down Expand Up @@ -58,4 +60,5 @@ module.exports = {
CertError,
DecryptError,
ForbiddenIPError,
RelayOutboundConfigError,
};
90 changes: 90 additions & 0 deletions lib/utils/httpsHelper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const https = require('https');
const tls = require('tls');
const Datatypes = require('./datatypes');
const certHelper = require('./certHelper');
const HttpsProxyAgent = require('./proxyAgent');

const origCreateSecureContext = tls.createSecureContext;

/**
* @param {String} apiKey
* @returns {void}
*/
const overloadHttpsModule = async (
apiKey,
tunnelHostname,
domainFilter,
debugRequests = false,
evClient,
originalRequest
) => {
let x509 = null;

async function updateCertificate() {
const pem = await evClient.getCert();
let cert = pem.toString();
x509 = certHelper.parseX509(cert);
tls.createSecureContext = (options) => {
const context = origCreateSecureContext(options);
context.context.addCACert(pem);
return context;
};
}

function isCertificateInvalid() {
if (!Datatypes.isDefined(x509)) {
return true;
}
const epoch = new Date().valueOf();
return (
epoch > new Date(x509.validTo).valueOf() ||
epoch < new Date(x509.validFrom).valueOf()
);
}

function getDomainFromArgs(args) {
if (typeof args[0] === 'string') {
return new URL(args[0]).host;
}

if (args.url) {
return args.url.match(domainRegex)[0];
}

let domain;
for (const arg of args) {
if (arg instanceof Object) {
domain = domain || arg.hostname || arg.host;
}
}
return domain;
}

function wrapMethodRequest(...args) {
const domain = getDomainFromArgs(args);
const shouldProxy = domainFilter(domain);
if (debugRequests) {
console.log(
`EVERVAULT DEBUG :: Request to domain: ${domain}, Outbound Proxy enabled: ${shouldProxy}`
);
}
args = args.map((arg) => {
if (shouldProxy && arg instanceof Object) {
arg.agent = new HttpsProxyAgent(
tunnelHostname,
updateCertificate,
isCertificateInvalid
);
arg.headers = { ...arg.headers, 'Proxy-Authorization': apiKey };
}
return arg;
});
return originalRequest.apply(this, args);
}

https.request = wrapMethodRequest;
};

module.exports = {
overloadHttpsModule,
};
Loading

0 comments on commit fb2e830

Please sign in to comment.