Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/hooks/plugin-install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');

Expand Down
19 changes: 18 additions & 1 deletion src/services/plugins.js
Original file line number Diff line number Diff line change
@@ -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'],
Expand All @@ -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;
}
Expand Down
23 changes: 23 additions & 0 deletions test/hooks/plugin-install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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');
});
});
});
46 changes: 46 additions & 0 deletions test/services/plugins.test.js
Original file line number Diff line number Diff line change
@@ -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;
});
});
});
});