- Get link
- X
- Other Apps
- Get link
- X
- Other Apps
The good thing about having experience with multiple frameworks is that you can compare them better, but also see missing 'features' in a framework. One of the features I'm missing in Laravel is that there is no standard in validating the configuration of an application to avoid misconfigurations. It's something I know from the Symfony framework and it helps greatly in upgrading packages or avoid typo's.
Recently I ran at my work in a potential security leak if I did not find it before going live. The reason was that a Laravel package was updated, but the application configuration of the package was not. The package update assumes I would have set a blacklist in the configuration. Since the configuration was not updated, there was no blacklist configured, so everything was allowed including searching on the password and activation token column of users.
Doing it manually
As a Laravel package developer I could of course do things manually in my service provider to avoid application misconfiguration by either throwing an error or have a fallback. Nothing witholds me from binding all the configuration parameters explicitly:
class MyLibraryServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->bind(MyServiceWithConfig::class, function () {
return new MyServiceWithConfig(config('my_library.uri') ?? 'http://localhost:42');
});
}
}
This is perfectly fine for a simple package, but it also makes it more work as a developer to maintain this package especially if the same configuration setting is used for multiple services and they should all have the same default value.
Always validating the configuration
So how about always overwriting the config service and santize our output?
class MyLibraryServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->extend('config', function ($config) {
if (empty($config->get('my_library.name')) {
throw new \LogicException('my_library.name config should not be empty');
}
$config->set('my_library.uri', $config->get('my_library.uri') ?? null);
return $config;
});
$this->app->bind(MyServiceWithConfig::class, function () {
return new MyServiceWithConfig(config('my_library.uri'));
});
}
}
If we use our config values in multiple service definitions then this solution benefits from only having to santize the configuration once. The downside is that it's still manual work and often this code could get very unreadable on large configurations. But the worst of them all is that there's a performance hit. On every request we are manually validating and modifying the configuration even when you use optimization steps like artisan config:cache. In production the configuration never changes, so this is a typical 'Don't repeat execution' case.
You only want to validate and sanitize your package configuration once in production. In Symfony this works out of the box without the developer having to think about it.
Storing and restoring a validated cache
Once we have a sanitized configuration from an unsanitized configuration, we need to be able to store and restore the value in a fast way that should be faster than resanitizing the configuration. So what is the fastest way to restore a validated cache? Imagine we have a ConfigStorage that stores a resolved configuration or restores one if it is possible, then there are several solutions how to store and restore it.
You could use the php functions serialize() and unserialize() to store the cache. The serialized cache could be stored in Redis, file system, database or whatever you like and also works with weird stuff like using objects and circular references in your configuration:
class ConfigStorage {
private const STORAGE_PATH = __DIR__ . '/storage.meta';
public function store(array $config): void
{
file_put_contents(self::STORAGE_PATH, serialize($config));
}
public function restore(): ?array {
// no is_readable check or anything since there are racing conditions.... Use nasty @ to keep example small.
$result = @unserialize(file_get_contents(self::STORAGE_PATH));
return is_array($result) ? $result : null;
}
}
You could also use the php functions json_encode() and json_decode() to restore the configuration.
class ConfigStorage {
private const STORAGE_PATH = __DIR__ . '/storage.json';
public function store(array $config): void
{
file_put_contents(self::STORAGE_PATH, json_encode($config));
}
public function restore(): ?array {
// no is_readable check or anything since there are racing conditions.... Use nasty @ to keep example small.
$result = @json_decode(file_get_contents(self::STORAGE_PATH), true);
return is_array($result) ? $result : null;
}
}
The last option would be to generate a php file that returns the same output and if it is created you just do require $cacheFile; to let php load your configuration.
class ConfigStorage {
// .phpinc extension gives syntax highlighting in most IDE's in case you open the file, but skips phpcs checks etc.
private const STORAGE_PATH = __DIR__ . '/storage.phpinc';
public function store(array $config): void
{
file_put_contents(self::STORAGE_PATH, '<?php' . PHP_EOL . 'return ' . var_export($config, true) . ';');
}
public function restore(): ?array {
// no is_readable check or anything since there are racing conditions.... Use nasty try catch to keep it small:
try {
$result = require self::STORAGE_PATH;
return is_array($result) ? $result : null;
} catch(Throwable) {
return null;
}
}
}
While the last one looks like an evil eval statement hidden behind require(): the last one is significantly faster than the other solutions. The reason is that unserialize() and json_decode() requires to be parsed on every request next to the php code being parsed. Php files can be cached with php opcache and only requires to be parsed once. Of course it requires to install the opcache extension.
In fact if you replace require with eval, you will get a similar performance than using unserialize or json_decode. The only downside of this option is that you have to be really sure that the generated PHP file is always valid and is free of injecting PHP code.
How does Symfony validate and cache the configuration?
Symfony caches the configuration in PHP files. A library written for Symfony introduces 1 to 3 classes to connect to a Symfony application: the bundle class, the extension class and the configuration class. The bundle could add some hooks to and registers the extension class. The extension class changes the service container before running the application and can process the configuration with the configuration class. The configuration class creates a configuration tree specifically for the bundle for validating, but also for introspection, so an IDE could autocomplete the configuration.
In a Symfony application the library configuration can often be found in config/packages/<package-name>.yaml or in the general config/services.yaml and this will only be parsed once in production.
Symfony does all the configuration and parsing magic once with the symfony/config component. It uses a ConfigCache class that has a isFresh() method that returns true if the caching should not be recalculated again:
use Symfony\Component\Config\ConfigCache;
$cachePath = __DIR__.'/cache/example.phpinc';
// the second argument indicates whether or not you want to use debug mode.
// if false, the isFresh check is not repeated on multiple requests once the cache file is created.
$libraryMatcherCache = new ConfigCache($cachePath, true);
if (!$libraryMatcherCache->isFresh()) {
$resources = [];
$libraryConfig = [];
// recalculate/revalidate caching + library config
// var_export can be used to create a php that returns an array really fast
$code = '<?php return ' . var_export($libraryConfig, true) . ';';
$libraryMatcherCache->write($code, $resources);
}
// you may want to require the cached code:
$config = require $cachePath;
The $resources variable can contain a list of resources, for example I have to revalidate the configuration if a file exists or if the code of a class changes. For example if we change the Configuration class, we would like to revalidate the code right away.
Validating a configuration with sensible error messages
The configuration of your package can be made with a configuration class that returns a configuration tree. This configuration class is the same as the one you can use in a Symfony bundle:
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('library_name');
$treeBuilder->getRootNode()->children();
->scalarNode('api_uri')->required()->end();
->booleanNode('enable_core')->defaultValue(true)->end();
->scalarNode('encryption_key')->end();
return $treeBuilder;
}
}
These configuration classes could be hard to read, but in general you define some properties like setting up the location of an API or an optional encryption key setting. To validate and sanitize the configuration array we can call it like this:
use Symfony\Component\Config\Definition\Processor;
$configuration = new Configuration();
$processor = new Processor();
$sanitizedConfig = $processor->processConfiguration($configuration, ['apie' => $rawConfig]);
While these classes are hard to write or read, they are better when being used in an application as they do give you sensible validation errors if a key is missing. For 'example if I make a library config mistake and add 'encrypted_key' and not 'encryption_key' I get a sensible error message that 'encrypted_key' does not exist and gives me a list of possible keys. If I miss a required option, like api_uri I will get the message the api_uri config should be set. You don't want to write this manually in your codebase!
Adding Symfony configuration validation in a service provider.
So by looking how it works in Symfony we try to see we could add similar configuration validation, where we can add it in the service provider. A service provider has a boot() and a register() function. register() is called first and it seems to us that it's better to extend the config service as early as possible. This prevents problems with other service providers calling config() directly in the register() function. So if the service provider only handles the configuration:
use MyLibrary\Config\Configuration;
use Illuminate\Config\Repository;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\Config\Resource\ReflectionClassResource;
class MyLibraryServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->extend('config', function (Repository $config) {
$this->sanitizeConfig($config);
return $config;
});
}
private function sanitizeConfig(Repository $config): void
{
$rawConfig = $config->get('my_library');
$path = storage_path('framework/cache/mylibrary-config.php');
// if the code of the service provider or the configuration class change, we want to revalidate the configuration
$resources = [
new ReflectionClassResource(new \ReflectionClass(Configuration::class)),
new ReflectionClassResource(new \ReflectionClass(static::class)),
];
$configCache = new ConfigCache($path, true);
if ($configCache->isFresh()) {
$processedConfig = require $path;
} else {
$configuration = new Configuration();
$processor = new Processor();
$processedConfig = $processor->processConfiguration($configuration, ['my_library' => $rawConfig]);
$code = '<?php' . PHP_EOL . 'return ' . var_export($processedConfig, true) . ';';
$configCache->write($code, $resources);
}
$config->set('my_library', $processedConfig);
}
}
So if we change the Configuration class or the Service provider class we always revalidate the configuration again. However if we have it cached and change environment variables we still have the old configuration. So how can we optimize this? With our current solution it is impossible to do in Laravel, because the configuration files in Laravel are PHP files, so we can not see which configuration options are handled by an environment variable. We can however fix it by using a different $path depending on the raw $config. A simple solution would be just changing this line:
$path = storage_path('framework/cache/mylibrary-config.' . md5(json_encode($rawConfig)) . '.php');
If an environment variable changes and is used by the library configuration $rawConfig will be different, so calculating md5 on a json encoded configuration would result in a different value. As a result it will only revalidate because it uses a different cache file.
More optimizations
The only optimization I can think of is not sanitizing the configuration after you ran ./artisan config:cache. When you run artisan config:cache Laravel will no longer search and read all files in the config path, but instead will load in a single file. However our santizeConfig method will still be executed because we will always extend the config service. The simplest solution is only calling it if the bootstrap file does not exist.
public function register()
{
if (!file_exists(base_path('bootstrap/cache/config.php'))) {
$this->app->extend('config', function (Repository $config) {
$this->sanitizeConfig($config);
return $config;
});
}
}
You might wonder if file_exists is not slow, but PHP caches file results in memory so once it checks if the file exists it will not bother doing this all the time, because then it would be faster just to re-run it again. Now our configuration will only be validated if I call config:cache and I have an optmized application for production!
Conclusion
So with a little bit of looking at different frameworks we can make our Laravel package give a better developer experience when creating a Laravel library. When I added it I finally found out why some of my integration tests were not working in Laravel. And it was all because the configuration key I set was 'encrypted_key', but my application was using 'encryption_key'. Sadly because of the architecture of the config service in Laravel it can not be optimized even more and it's either slightly slower than Symfony or just not flexible when using environment variables.
Don't like the Symfony solution? There are more libraries you can try and see if they work for you. My library works in Symfony and Laravel and it saves lots of time to only have one configuration validation code.
Comments
Post a Comment