From 8e93566c18b1b8b0de32aff64a2204f19f1105f8 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 1 Apr 2022 11:23:59 +0200 Subject: [PATCH] Validate config schema before loading it, fixes #10685 --- src/Composer/Factory.php | 58 +++++++++++++++++------ src/Composer/Json/JsonFile.php | 14 +++++- tests/Composer/Test/Json/JsonFileTest.php | 14 ++++++ 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index 697a742cb..43ddfa14f 100644 --- a/src/Composer/Factory.php +++ b/src/Composer/Factory.php @@ -40,6 +40,7 @@ use Composer\Downloader\TransportException; use Composer\Json\JsonValidationException; use Composer\Repository\InstalledRepositoryInterface; use Seld\JsonLint\JsonParser; +use UnexpectedValueException; use ZipArchive; /** @@ -170,18 +171,21 @@ class Factory // determine and add main dirs to the config $home = self::getHomeDir(); - $config->merge(array('config' => array( - 'home' => $home, - 'cache-dir' => self::getCacheDir($home), - 'data-dir' => self::getDataDir($home), - )), Config::SOURCE_DEFAULT); + $config->merge(array( + 'config' => array( + 'home' => $home, + 'cache-dir' => self::getCacheDir($home), + 'data-dir' => self::getDataDir($home), + ) + ), Config::SOURCE_DEFAULT); // load global config $file = new JsonFile($config->get('home').'/config.json'); if ($file->exists()) { - if ($io && $io->isDebug()) { - $io->writeError('Loading config file ' . $file->getPath()); + if ($io) { + $io->writeError('Loading config file ' . $file->getPath(), true, IOInterface::DEBUG); } + self::validateJsonSchema($io, $file); $config->merge($file->read(), $file->getPath()); } $config->setConfigSource(new JsonConfigSource($file)); @@ -205,26 +209,30 @@ class Factory // load global auth file $file = new JsonFile($config->get('home').'/auth.json'); if ($file->exists()) { - if ($io && $io->isDebug()) { - $io->writeError('Loading config file ' . $file->getPath()); + if ($io) { + $io->writeError('Loading config file ' . $file->getPath(), true, IOInterface::DEBUG); } + self::validateJsonSchema($io, $file, JsonFile::AUTH_SCHEMA); $config->merge(array('config' => $file->read()), $file->getPath()); } $config->setAuthConfigSource(new JsonConfigSource($file, true)); // load COMPOSER_AUTH environment variable if set if ($composerAuthEnv = Platform::getEnv('COMPOSER_AUTH')) { - $authData = json_decode($composerAuthEnv, true); - + $authData = json_decode($composerAuthEnv); if (null === $authData) { if ($io) { $io->writeError('COMPOSER_AUTH environment variable is malformed, should be a valid JSON object'); } } else { - if ($io && $io->isDebug()) { - $io->writeError('Loading auth config from COMPOSER_AUTH'); + if ($io) { + $io->writeError('Loading auth config from COMPOSER_AUTH', true, IOInterface::DEBUG); + } + self::validateJsonSchema($io, $authData, JsonFile::AUTH_SCHEMA, 'COMPOSER_AUTH'); + $authData = json_decode($composerAuthEnv, true); + if (null !== $authData) { + $config->merge(array('config' => $authData), 'COMPOSER_AUTH'); } - $config->merge(array('config' => $authData), 'COMPOSER_AUTH'); } } @@ -690,4 +698,26 @@ class Factory return rtrim(strtr($home, '\\', '/'), '/'); } + + /** + * @param mixed $fileOrData + * @param JsonFile::*_SCHEMA $schema + */ + private static function validateJsonSchema(?IOInterface $io, $fileOrData, int $schema = JsonFile::LAX_SCHEMA, ?string $source = null): void + { + try { + if ($fileOrData instanceof JsonFile) { + $fileOrData->validateSchema($schema); + } else { + JsonFile::validateJsonSchema($source, $fileOrData, $schema); + } + } catch (JsonValidationException $e) { + $msg = $e->getMessage().', this may result in errors and should be resolved:'.PHP_EOL.' - '.implode(PHP_EOL.' - ', $e->getErrors()); + if ($io) { + $io->writeError(''.$msg.''); + } else { + throw new UnexpectedValueException($msg); + } + } + } } diff --git a/src/Composer/Json/JsonFile.php b/src/Composer/Json/JsonFile.php index 987de5cbb..49f27f6a9 100644 --- a/src/Composer/Json/JsonFile.php +++ b/src/Composer/Json/JsonFile.php @@ -30,6 +30,7 @@ class JsonFile { public const LAX_SCHEMA = 1; public const STRICT_SCHEMA = 2; + public const AUTH_SCHEMA = 3; /** @deprecated Use \JSON_UNESCAPED_SLASHES */ public const JSON_UNESCAPED_SLASHES = 64; @@ -186,7 +187,9 @@ class JsonFile * @param string|null $schemaFile a path to the schema file * @throws JsonValidationException * @throws ParsingException - * @return bool true on success + * @return true true on success + * + * @phpstan-param self::*_SCHEMA $schema */ public function validateSchema(int $schema = self::STRICT_SCHEMA, ?string $schemaFile = null): bool { @@ -197,6 +200,11 @@ class JsonFile self::validateSyntax($content, $this->path); } + return self::validateJsonSchema($this->path, $data, $schema, $schemaFile); + } + + public static function validateJsonSchema($source, $data, int $schema, ?string $schemaFile = null): bool + { $isComposerSchemaFile = false; if (null === $schemaFile) { $isComposerSchemaFile = true; @@ -216,6 +224,8 @@ class JsonFile } elseif ($schema === self::STRICT_SCHEMA && $isComposerSchemaFile) { $schemaData->additionalProperties = false; $schemaData->required = array('name', 'description'); + } elseif ($schema === self::AUTH_SCHEMA && $isComposerSchemaFile) { + $schemaData = (object) array('$ref' => $schemaFile.'#/properties/config', '$schema'=> "https://json-schema.org/draft-04/schema#"); } $validator = new Validator(); @@ -226,7 +236,7 @@ class JsonFile foreach ((array) $validator->getErrors() as $error) { $errors[] = ($error['property'] ? $error['property'].' : ' : '').$error['message']; } - throw new JsonValidationException('"'.$this->path.'" does not match the expected JSON schema', $errors); + throw new JsonValidationException('"'.$source.'" does not match the expected JSON schema', $errors); } return true; diff --git a/tests/Composer/Test/Json/JsonFileTest.php b/tests/Composer/Test/Json/JsonFileTest.php index fa393fc41..8e79fad6b 100644 --- a/tests/Composer/Test/Json/JsonFileTest.php +++ b/tests/Composer/Test/Json/JsonFileTest.php @@ -239,6 +239,20 @@ class JsonFileTest extends TestCase unlink($schema); } + public function testAuthSchemaValidationWithCustomDataSource(): void + { + $json = json_decode('{"github-oauth": "foo"}'); + $expectedMessage = sprintf('"COMPOSER_AUTH" does not match the expected JSON schema'); + $expectedError = 'github-oauth : String value found, but an object is required'; + try { + JsonFile::validateJsonSchema('COMPOSER_AUTH', $json, JsonFile::AUTH_SCHEMA); + $this->fail('Expected exception to be thrown'); + } catch (JsonValidationException $e) { + $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertSame([$expectedError], $e->getErrors()); + } + } + public function testParseErrorDetectMissingCommaMultiline(): void { $json = '{