Add allow-plugins config value (#10314)

Fixes #5659

- Automatically switch off plugins by default in July 2022
- reword hash into object in schema

Co-authored-by: Nils Adermann <naderman@naderman.de>
main
Jordi Boggiano 2 years ago committed by GitHub
parent 04dbed27a9
commit a3e91b5be6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -24,6 +24,37 @@ helper is available:
}
```
## allow-plugins
Defaults to `null` (allow all plugins implicitly) for backwards compatibility until July 2022.
At that point the default will become `{}` and plugins will not load anymore unless allowed.
As of Composer 2.2.0, the `allow-plugins` option adds a layer of security
allowing you to restrict which Composer plugins are able to execute code during
a Composer run.
When a new plugin is first activated, which is not yet listed in the config option,
Composer will print a warning. If you run Composer interactively it will
prompt you to decide if you want to execute the plugin or not.
Use this setting to allow only packages you trust to execute code. Set it to
an object with package name patterns as keys. The values are **true** to allow
and **false** to disallow while suppressing further warnings and prompts.
```json
{
"config": {
"allow-plugins": {
"third-party/required-plugin": true,
"my-organization/*": true,
"unnecessary/plugin": false
}
}
}
```
You can also set the config option itself to `false` to disallow all plugins, or `true` to allow all plugins to run (NOT recommended).
## use-include-path
Defaults to `false`. If `true`, the Composer autoloader will also look for classes
@ -33,7 +64,7 @@ in the PHP include path.
Defaults to `dist` and can be any of `source`, `dist` or `auto`. This option
allows you to set the install method Composer will prefer to use. Can
optionally be a hash of patterns for more granular install preferences.
optionally be an object with package name patterns for keys for more granular install preferences.
```json
{

@ -138,42 +138,42 @@
},
"require": {
"type": "object",
"description": "This is a hash of package name (keys) and version constraints (values) that are required to run this package.",
"description": "This is an object of package name (keys) and version constraints (values) that are required to run this package.",
"additionalProperties": {
"type": "string"
}
},
"require-dev": {
"type": "object",
"description": "This is a hash of package name (keys) and version constraints (values) that this package requires for developing it (testing tools and such).",
"description": "This is an object of package name (keys) and version constraints (values) that this package requires for developing it (testing tools and such).",
"additionalProperties": {
"type": "string"
}
},
"replace": {
"type": "object",
"description": "This is a hash of package name (keys) and version constraints (values) that can be replaced by this package.",
"description": "This is an object of package name (keys) and version constraints (values) that can be replaced by this package.",
"additionalProperties": {
"type": "string"
}
},
"conflict": {
"type": "object",
"description": "This is a hash of package name (keys) and version constraints (values) that conflict with this package.",
"description": "This is an object of package name (keys) and version constraints (values) that conflict with this package.",
"additionalProperties": {
"type": "string"
}
},
"provide": {
"type": "object",
"description": "This is a hash of package name (keys) and version constraints (values) that this package provides in addition to this package's name.",
"description": "This is an object of package name (keys) and version constraints (values) that this package provides in addition to this package's name.",
"additionalProperties": {
"type": "string"
}
},
"suggest": {
"type": "object",
"description": "This is a hash of package name (keys) and descriptions (values) that this package suggests work well with it (this will be suggested to the user during installation).",
"description": "This is an object of package name (keys) and descriptions (values) that this package suggests work well with it (this will be suggested to the user during installation).",
"additionalProperties": {
"type": "string"
}
@ -217,7 +217,7 @@
"properties": {
"psr-0": {
"type": "object",
"description": "This is a hash of namespaces (keys) and the directories they can be found into (values, can be arrays of paths) by the autoloader.",
"description": "This is an object of namespaces (keys) and the directories they can be found into (values, can be arrays of paths) by the autoloader.",
"additionalProperties": {
"type": ["string", "array"],
"items": {
@ -227,7 +227,7 @@
},
"psr-4": {
"type": "object",
"description": "This is a hash of namespaces (keys) and the PSR-4 directories they can map to (values, can be arrays of paths) by the autoloader.",
"description": "This is an object of namespaces (keys) and the PSR-4 directories they can map to (values, can be arrays of paths) by the autoloader.",
"additionalProperties": {
"type": ["string", "array"],
"items": {
@ -283,11 +283,18 @@
"properties": {
"platform": {
"type": "object",
"description": "This is a hash of package name (keys) and version (values) that will be used to mock the platform packages on this machine.",
"description": "This is an object of package name (keys) and version (values) that will be used to mock the platform packages on this machine.",
"additionalProperties": {
"type": ["string", "boolean"]
}
},
"allow-plugins": {
"type": ["object", "boolean"],
"description": "This is an object of {\"pattern\": true|false} with packages which are allowed to be loaded as plugins, or true to allow all, false to allow none. Defaults to {} which prompts when an unknown plugin is added.",
"additionalProperties": {
"type": ["boolean"]
}
},
"process-timeout": {
"type": "integer",
"description": "The timeout in seconds for process executions, defaults to 300 (5mins)."
@ -302,7 +309,10 @@
},
"preferred-install": {
"type": ["string", "object"],
"description": "The install method Composer will prefer to use, defaults to auto and can be any of source, dist, auto, or a hash of {\"pattern\": \"preference\"}."
"description": "The install method Composer will prefer to use, defaults to auto and can be any of source, dist, auto, or an object of {\"pattern\": \"preference\"}.",
"additionalProperties": {
"type": ["string"]
}
},
"notify-on-install": {
"type": "boolean",
@ -317,21 +327,21 @@
},
"github-oauth": {
"type": "object",
"description": "A hash of domain name => github API oauth tokens, typically {\"github.com\":\"<token>\"}.",
"description": "An object of domain name => github API oauth tokens, typically {\"github.com\":\"<token>\"}.",
"additionalProperties": {
"type": "string"
}
},
"gitlab-oauth": {
"type": "object",
"description": "A hash of domain name => gitlab API oauth tokens, typically {\"gitlab.com\":\"<token>\"}.",
"description": "An object of domain name => gitlab API oauth tokens, typically {\"gitlab.com\":\"<token>\"}.",
"additionalProperties": {
"type": "string"
}
},
"gitlab-token": {
"type": "object",
"description": "A hash of domain name => gitlab private tokens, typically {\"gitlab.com\":\"<token>\"}.",
"description": "An object of domain name => gitlab private tokens, typically {\"gitlab.com\":\"<token>\"}.",
"additionalProperties": {
"type": "string"
}
@ -342,7 +352,7 @@
},
"bearer": {
"type": "object",
"description": "A hash of domain name => bearer authentication token, for example {\"example.com\":\"<token>\"}.",
"description": "An object of domain name => bearer authentication token, for example {\"example.com\":\"<token>\"}.",
"additionalProperties": {
"type": "string"
}
@ -372,7 +382,7 @@
},
"http-basic": {
"type": "object",
"description": "A hash of domain name => {\"username\": \"...\", \"password\": \"...\"}.",
"description": "An object of domain name => {\"username\": \"...\", \"password\": \"...\"}.",
"additionalProperties": {
"type": "object",
"required": ["username", "password"],
@ -631,7 +641,7 @@
"properties": {
"psr-0": {
"type": "object",
"description": "This is a hash of namespaces (keys) and the directories they can be found in (values, can be arrays of paths) by the autoloader.",
"description": "This is an object of namespaces (keys) and the directories they can be found in (values, can be arrays of paths) by the autoloader.",
"additionalProperties": {
"type": ["string", "array"],
"items": {
@ -641,7 +651,7 @@
},
"psr-4": {
"type": "object",
"description": "This is a hash of namespaces (keys) and the PSR-4 directories they can map to (values, can be arrays of paths) by the autoloader.",
"description": "This is an object of namespaces (keys) and the PSR-4 directories they can map to (values, can be arrays of paths) by the autoloader.",
"additionalProperties": {
"type": ["string", "array"],
"items": {

@ -214,7 +214,7 @@ EOT
protected function execute(InputInterface $input, OutputInterface $output)
{
// Open file in editor
if ($input->getOption('editor')) {
if (true === $input->getOption('editor')) {
$editor = escapeshellcmd(Platform::getEnv('EDITOR'));
if (!$editor) {
if (Platform::isWindows()) {
@ -235,20 +235,20 @@ EOT
return 0;
}
if (!$input->getOption('global')) {
if (false === $input->getOption('global')) {
$this->config->merge($this->configFile->read(), $this->configFile->getPath());
$this->config->merge(array('config' => $this->authConfigFile->exists() ? $this->authConfigFile->read() : array()), $this->authConfigFile->getPath());
}
// List the configuration of the file settings
if ($input->getOption('list')) {
if (true === $input->getOption('list')) {
$this->listConfiguration($this->config->all(), $this->config->raw(), $output, null, (bool) $input->getOption('source'));
return 0;
}
$settingKey = $input->getArgument('setting-key');
if (!$settingKey || !is_string($settingKey)) {
if (!is_string($settingKey)) {
return 0;
}
@ -446,6 +446,7 @@ EOT
'github-expose-hostname' => array($booleanValidator, $booleanNormalizer),
'htaccess-protect' => array($booleanValidator, $booleanNormalizer),
'lock' => array($booleanValidator, $booleanNormalizer),
'allow-plugins' => array($booleanValidator, $booleanNormalizer),
'platform-check' => array(
function ($val) {
return in_array($val, array('php-only', 'true', 'false', '1', '0'), true);
@ -553,6 +554,28 @@ EOT
return 0;
}
// handle allow-plugins config setting elements true or false to add/remove
if (Preg::isMatch('{^allow-plugins\.([a-zA-Z0-9/*-]+)}', $settingKey, $matches)) {
if ($input->getOption('unset')) {
$this->configSource->removeConfigSetting($settingKey);
return 0;
}
if (true !== $booleanValidator($values[0])) {
throw new \RuntimeException(sprintf(
'"%s" is an invalid value',
$values[0]
));
}
$normalizedValue = $booleanNormalizer($values[0]);
$this->configSource->addConfigSetting($settingKey, $normalizedValue);
return 0;
}
// handle properties
$uniqueProps = array(
'name' => array('is_string', function ($val) {

@ -34,6 +34,7 @@ class Config
public static $defaultConfig = array(
'process-timeout' => 300,
'use-include-path' => false,
'allow-plugins' => null, // null for BC for now, will become array() after July 2022
'use-parent-dir' => 'prompt',
'preferred-install' => 'dist',
'notify-on-install' => true,
@ -117,6 +118,12 @@ class Config
{
// load defaults
$this->config = static::$defaultConfig;
// TODO after July 2022 remove this and update the default value above in self::$defaultConfig + remove note from 06-config.md
if (strtotime('2022-07-01') < time()) {
$this->config['allow-plugins'] = array();
}
$this->repositories = static::$defaultRepositories;
$this->useEnvironment = (bool) $useEnvironment;
$this->baseDir = $baseDir;
@ -165,7 +172,7 @@ class Config
/**
* Merges new config values with the existing ones (overriding)
*
* @param array<string, mixed> $config
* @param array{config?: array<string, mixed>, repositories?: array<mixed>} $config
* @param string $source
*
* @return void
@ -175,10 +182,15 @@ class Config
// override defaults with given config
if (!empty($config['config']) && is_array($config['config'])) {
foreach ($config['config'] as $key => $val) {
if (in_array($key, array('bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer')) && isset($this->config[$key])) {
if (in_array($key, array('bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer'), true) && isset($this->config[$key])) {
$this->config[$key] = array_merge($this->config[$key], $val);
$this->setSourceOfConfigValue($val, $key, $source);
} elseif (in_array($key, array('gitlab-domains', 'github-domains')) && isset($this->config[$key])) {
} elseif (in_array($key, array('allow-plugins'), true) && isset($this->config[$key]) && is_array($this->config[$key])) {
// merging $val first to get the local config on top of the global one, then appending the global config,
// then merging local one again to make sure the values from local win over global ones for keys present in both
$this->config[$key] = array_merge($val, $this->config[$key], $val);
$this->setSourceOfConfigValue($val, $key, $source);
} elseif (in_array($key, array('gitlab-domains', 'github-domains'), true) && isset($this->config[$key])) {
$this->config[$key] = array_unique(array_merge($this->config[$key], $val));
$this->setSourceOfConfigValue($val, $key, $source);
} elseif ('preferred-install' === $key && isset($this->config[$key])) {

@ -287,7 +287,7 @@ class JsonConfigSource implements ConfigSourceInterface
} catch (JsonValidationException $e) {
// restore contents to the original state
file_put_contents($this->file->getPath(), $contents);
throw new \RuntimeException('Failed to update composer.json with a valid format, reverting to the original content. Please report an issue to us with details (command you run and a copy of your composer.json).', 0, $e);
throw new \RuntimeException('Failed to update composer.json with a valid format, reverting to the original content. Please report an issue to us with details (command you run and a copy of your composer.json). '.PHP_EOL.implode(PHP_EOL, $e->getErrors()), 0, $e);
}
if ($newFile) {

@ -15,6 +15,7 @@ namespace Composer\Plugin;
use Composer\Composer;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\IO\IOInterface;
use Composer\Package\BasePackage;
use Composer\Package\CompletePackage;
use Composer\Package\Package;
use Composer\Package\Version\VersionParser;
@ -52,6 +53,16 @@ class PluginManager
/** @var array<string, PluginInterface> */
protected $registeredPlugins = array();
/**
* @var array<string, bool>|null
*/
private $allowPluginRules;
/**
* @var array<string, bool>|null
*/
private $allowGlobalPluginRules;
/** @var int */
private static $classCounter = 0;
@ -70,6 +81,9 @@ class PluginManager
$this->globalComposer = $globalComposer;
$this->versionParser = new VersionParser();
$this->disablePlugins = $disablePlugins;
$this->allowPluginRules = $this->parseAllowedPlugins($composer->getConfig()->get('allow-plugins'));
$this->allowGlobalPluginRules = $this->parseAllowedPlugins($globalComposer !== null ? $globalComposer->getConfig()->get('allow-plugins') : false);
}
/**
@ -84,7 +98,7 @@ class PluginManager
}
$repo = $this->composer->getRepositoryManager()->getLocalRepository();
$globalRepo = $this->globalComposer ? $this->globalComposer->getRepositoryManager()->getLocalRepository() : null;
$globalRepo = $this->globalComposer !== null ? $this->globalComposer->getRepositoryManager()->getLocalRepository() : null;
$this->loadRepository($repo, false);
if ($globalRepo) {
$this->loadRepository($globalRepo, true);
@ -150,6 +164,11 @@ class PluginManager
return;
}
if (!$this->isPluginAllowed($package->getName(), $isGlobalPlugin)) {
$this->io->writeError('Skipped loading "'.$package->getName() . '" '.($isGlobalPlugin ? '(installed globally) ' : '').'as it is not in config.allow-plugins', true, IOInterface::DEBUG);
return;
}
if ($package->getType() === 'composer-plugin') {
$requiresComposer = null;
foreach ($package->getRequires() as $link) { /** @var Link $link */
@ -194,7 +213,7 @@ class PluginManager
$classes = is_array($extra['class']) ? $extra['class'] : array($extra['class']);
$localRepo = $this->composer->getRepositoryManager()->getLocalRepository();
$globalRepo = $this->globalComposer ? $this->globalComposer->getRepositoryManager()->getLocalRepository() : null;
$globalRepo = $this->globalComposer !== null ? $this->globalComposer->getRepositoryManager()->getLocalRepository() : null;
$rootPackage = clone $this->composer->getPackage();
$rootPackageRepo = new RootPackageRepository($rootPackage);
@ -357,6 +376,13 @@ class PluginManager
*/
public function addPlugin(PluginInterface $plugin, $isGlobalPlugin = false, PackageInterface $sourcePackage = null)
{
if ($sourcePackage === null) {
trigger_error('Calling PluginManager::addPlugin without $sourcePackage is deprecated, if you are using this please get in touch with us to explain the use case', E_USER_DEPRECATED);
} elseif (!$this->isPluginAllowed($sourcePackage->getName(), $isGlobalPlugin)) {
$this->io->writeError('Skipped loading "'.get_class($plugin).' from '.$sourcePackage->getName() . '" '.($isGlobalPlugin ? '(installed globally) ' : '').' as it is not in config.allow-plugins', true, IOInterface::DEBUG);
return;
}
$details = array();
if ($sourcePackage) {
$details[] = 'from '.$sourcePackage->getName();
@ -597,4 +623,109 @@ class PluginManager
return $capabilities;
}
/**
* @param array<string, bool>|bool|null $allowPluginsConfig
* @return array<string, bool>|null
*/
private function parseAllowedPlugins($allowPluginsConfig)
{
if (null === $allowPluginsConfig) {
return null;
}
if (true === $allowPluginsConfig) {
return array('{}' => true);
}
if (false === $allowPluginsConfig) {
return array('{^$}D' => false);
}
$rules = array();
foreach ($allowPluginsConfig as $pattern => $allow) {
$rules[BasePackage::packageNameToRegexp($pattern)] = $allow;
}
return $rules;
}
/**
* @param string $package
* @param bool $isGlobalPlugin
* @return bool
*/
private function isPluginAllowed($package, $isGlobalPlugin)
{
static $warned = array();
$rules = $isGlobalPlugin ? $this->allowGlobalPluginRules : $this->allowPluginRules;
if ($rules === null) {
if (!$this->io->isInteractive()) {
if (!isset($warned['all'])) {
$this->io->writeError('<warning>For additional security you should declare the allow-plugins config with a list of packages names that are allowed to run code. See https://getcomposer.org/allow-plugins</warning>');
$this->io->writeError('<warning>You have until July 2022 to add the setting. Composer will then switch the default behavior to disallow all plugins.</warning>');
$warned['all'] = true;
}
// if no config is defined we allow all plugins for BC
return true;
}
// keep going and prompt the user
$rules = array();
}
foreach ($rules as $pattern => $allow) {
if (Preg::isMatch($pattern, $package)) {
return $allow === true;
}
}
if (!isset($warned[$package])) {
if ($this->io->isInteractive()) {
$composer = $isGlobalPlugin && $this->globalComposer !== null ? $this->globalComposer : $this->composer;
$this->io->writeError('<warning>'.$package.($isGlobalPlugin ? ' (installed globally)' : '').' contains a Composer plugin which is currently not in your allow-plugins config. See https://getcomposer.org/allow-plugins</warning>');
while (true) {
switch ($answer = $this->io->ask('<warning>Do you trust "'.$package.'" to execute code and wish to enable it now? (writes "allow-plugins" to composer.json) [y,n,d,?]</warning> ', '?')) {
case 'y':
case 'n':
case 'd':
$allow = $answer === 'y';
// persist answer in current rules to avoid prompting again if the package gets reloaded
if ($isGlobalPlugin) {
$this->allowGlobalPluginRules[BasePackage::packageNameToRegexp($package)] = $allow;
} else {
$this->allowPluginRules[BasePackage::packageNameToRegexp($package)] = $allow;
}
// persist answer in composer.json if it wasn't simply discarded
if ($answer === 'y' || $answer === 'n') {
$composer->getConfig()->getConfigSource()->addConfigSetting('allow-plugins.'.$package, $allow);
}
return $allow;
case '?':
default:
$this->io->writeError(array(
'y - add package to allow-plugins in composer.json and let it run immediately',
'n - add package (as disallowed) to allow-plugins in composer.json to suppress further prompts',
'd - discard this, do not change composer.json and do not allow the plugin to run',
'? - print help'
));
break;
}
}
} else {
$this->io->writeError('<warning>'.$package.($isGlobalPlugin ? ' (installed globally)' : '').' contains a Composer plugin which is blocked by your allow-plugins config. You may add it to the list if you consider it safe. See https://getcomposer.org/allow-plugins</warning>');
$this->io->writeError('<warning>You can run "composer '.($isGlobalPlugin ? 'global ' : '').'config --no-plugins allow-plugins.'.$package.' [true|false]" to enable it (true) or keep it disabled and suppress this warning (false)</warning>');
}
$warned[$package] = true;
}
return false;
}
}

@ -17,5 +17,8 @@
},
"autoload": {
"classmap": ["Hooks.php"]
},
"config": {
"allow-plugins": true
}
}

@ -17,5 +17,8 @@
},
"autoload": {
"classmap": ["Hooks.php"]
},
"config": {
"allow-plugins": true
}
}

@ -114,16 +114,17 @@ class PluginInstallerTest extends TestCase
$this->composer->setEventDispatcher(new EventDispatcher($this->composer, $this->io));
$this->composer->setPackage(new RootPackage('dummy/root', '1.0.0.0', '1.0.0'));
$this->pm = new PluginManager($this->io, $this->composer);
$this->composer->setPluginManager($this->pm);
$config->merge(array(
'config' => array(
'vendor-dir' => $this->directory.'/Fixtures/',
'home' => $this->directory.'/Fixtures',
'bin-dir' => $this->directory.'/Fixtures/bin',
'allow-plugins' => true,
),
));
$this->pm = new PluginManager($this->io, $this->composer);
$this->composer->setPluginManager($this->pm);
}
protected function tearDown()
@ -145,7 +146,10 @@ class PluginInstallerTest extends TestCase
$plugins = $this->pm->getPlugins();
$this->assertEquals('installer-v1', $plugins[0]->version); // @phpstan-ignore-line
$this->assertEquals('activate v1'.PHP_EOL, $this->io->getOutput());
$this->assertEquals(
'activate v1'.PHP_EOL,
$this->io->getOutput()
);
}
public function testInstallMultiplePlugins()

Loading…
Cancel
Save