diff --git a/src/hooks/plugin-install.js b/src/hooks/plugin-install.js index a6f915b06..9a4d5401e 100644 --- a/src/hooks/plugin-install.js +++ b/src/hooks/plugin-install.js @@ -10,7 +10,7 @@ const AUTOCOMLETE_INSTALL_WARNING = `If you’re using autocomplete, you’ll ne module.exports = async function pluginInstall(options) { logger.warn(chalk.yellowBright(`${AUTOCOMLETE_INSTALL_WARNING}`)); - if (!isTwilioPlugin(options.plugin.name)) { + if (!isTwilioPlugin(options.plugin.name, options.plugin.url)) { logger.warn('WARNING!!! You are attempting to install a plugin from an untrusted source.'); logger.warn('It could contain malicious software or in other ways compromise your system.'); diff --git a/src/services/plugins.js b/src/services/plugins.js index 599b54722..57b3b9c64 100644 --- a/src/services/plugins.js +++ b/src/services/plugins.js @@ -1,5 +1,14 @@ const ALLOWED_ORGS = ['@twilio/', '@twilio-labs/', '@dabblelab/plugin-autopilot']; +/* + * Trusted hosts for URL/tarball-based plugin installs, which carry no npm + * package name to match against ALLOWED_ORGS. A host matches if it equals an + * entry exactly or is a subdomain of one (e.g. cli.twilio.world). + */ +const ALLOWED_HOSTS = ['twilio.world']; + +const isAllowedHost = (hostname) => ALLOWED_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`)); + const PLUGIN_COMMANDS = { '@twilio-labs/plugin-dev-phone': ['dev-phone'], '@twilio-labs/plugin-flex': ['flex'], @@ -12,7 +21,15 @@ const PLUGIN_COMMANDS = { '@dabblelab/plugin-autopilot': ['autopilot'], }; -exports.isTwilioPlugin = (pluginName) => { +exports.isTwilioPlugin = (pluginName, pluginUrl) => { + if (pluginUrl !== undefined) { + try { + return isAllowedHost(new URL(pluginUrl).hostname); + } catch (error) { + return false; + } + } + if (pluginName === undefined) { return false; } diff --git a/test/hooks/plugin-install.test.js b/test/hooks/plugin-install.test.js index 5cd64cd69..ff12d7332 100644 --- a/test/hooks/plugin-install.test.js +++ b/test/hooks/plugin-install.test.js @@ -28,6 +28,18 @@ const getUndefinedPlugin = () => ({ }, }); +const getTrustedUrlPlugin = () => ({ + plugin: { + url: 'https://twilio.world/cli/plugin/abc-123', + }, +}); + +const getUntrustedUrlPlugin = () => ({ + plugin: { + url: 'https://evil.example.com/plugin.tgz', + }, +}); + describe('hooks', () => { describe('plugin-install', () => { before(() => { @@ -60,5 +72,16 @@ describe('hooks', () => { await pluginFunc.call(ctx, getTwilioLabsPlugin()); expect(ctx.stderr).to.contain('twilio autocomplete'); }); + + test.stderr().it('outputs nothing when a trusted url plugin is installed', async (ctx) => { + await pluginFunc.call(ctx, getTrustedUrlPlugin()); + expect(ctx.stderr).to.not.contain('WARNING'); + }); + + test.stderr().it('warning when an untrusted url plugin is installed', async (ctx) => { + ctx.exit = sinon.stub().resolves(1); + await pluginFunc.call(ctx, getUntrustedUrlPlugin()); + expect(ctx.stderr).to.contain('WARNING'); + }); }); }); diff --git a/test/services/plugins.test.js b/test/services/plugins.test.js new file mode 100644 index 000000000..563354896 --- /dev/null +++ b/test/services/plugins.test.js @@ -0,0 +1,46 @@ +const { expect } = require('@twilio/cli-test'); + +const { isTwilioPlugin } = require('../../src/services/plugins'); + +describe('services', () => { + describe('plugins', () => { + describe('isTwilioPlugin', () => { + it('trusts an npm package under an allowed org', () => { + expect(isTwilioPlugin('@twilio/debugger')).to.be.ok; + expect(isTwilioPlugin('@twilio-labs/debugger')).to.be.ok; + }); + + it('does not trust an npm package under a disallowed org', () => { + expect(isTwilioPlugin('@twilio-labs-h4x0r/plugin-serverless')).to.not.be.ok; + }); + + it('does not trust when the package name is undefined and there is no url', () => { + expect(isTwilioPlugin(undefined)).to.be.false; + }); + + it('trusts a url on an allowed host', () => { + expect(isTwilioPlugin(undefined, 'https://twilio.world/cli/plugin/abc-123')).to.be.true; + }); + + it('trusts a url on an allowed subdomain', () => { + expect(isTwilioPlugin(undefined, 'https://cli.twilio.world/plugin.tgz')).to.be.true; + }); + + it('does not trust a url on a disallowed host', () => { + expect(isTwilioPlugin(undefined, 'https://evil.example.com/plugin.tgz')).to.be.false; + }); + + it('does not trust a url on a host that merely contains the allowed host as a substring', () => { + expect(isTwilioPlugin(undefined, 'https://twilio.world.evil.com/plugin.tgz')).to.be.false; + }); + + it('does not trust a malformed url', () => { + expect(isTwilioPlugin(undefined, 'not-a-url')).to.be.false; + }); + + it('prefers the url check over the name check when both are present', () => { + expect(isTwilioPlugin('@twilio/debugger', 'https://evil.example.com/plugin.tgz')).to.be.false; + }); + }); + }); +});