diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index d116b44bf29..1e5c4c91054 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -26,7 +26,7 @@ - Deprecated `craft\helpers\HtmlPurifier::cleanUtf8()`. - Deprecated `craft\helpers\HtmlPurifier::convertToUtf8()`. `CraftCms\Cms\Support\Str::convertToUtf8()` should be used instead. - Deprecated `craft\helpers\HtmlPurifier::configure()`. `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers::defaults()` or a custom sanitizer registration should be used instead. -- Deprecated `config/craft/htmlpurifier/*.json` sanitizer config files. Sanitizers should be registered on `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers` instead. +- Deprecated `config/craft/htmlpurifier/*.json` sanitizer config files. Sanitizers should be registered as Symfony-style array config files in `config/craft/sanitizers/`, or on `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers` instead. - Deprecated `craft\services\Path`. `CraftCms\Cms\Support\Path` should be used instead. - Deprecated `craft\helpers\SessionHelper`. `Illuminate\Support\Facades\Session` should be used instead. - Deprecated `craft\helpers\Sequence`. `CraftCms\Cms\Support\Sequence` should be used instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 01279e30529..082c6400fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Added support for SQLite backups and restores. ([#18803](https://github.com/craftcms/cms/pull/18803)) +- Added support for Symfony-style array config files in `config/craft/sanitizers/`. ([#18808](https://github.com/craftcms/cms/pull/18808)) - The `craftAsset()` Twig function now resolves to Vite versioned assets. ([#18801](https://github.com/craftcms/cms/pull/18801)) - Deprecated the `csrfTokenName`, `enableCsrfCookie`, and `enableCsrfProtection` general config settings. ([#18806](https://github.com/craftcms/cms/pull/18806)) diff --git a/docs/html-sanitizers.md b/docs/html-sanitizers.md index 779a6b733a3..03a46bcfb3e 100644 --- a/docs/html-sanitizers.md +++ b/docs/html-sanitizers.md @@ -83,6 +83,26 @@ use CraftCms\Cms\Support\Facades\HtmlSanitizers; $cleanHtml = HtmlSanitizers::sanitizer('links-only')->sanitize($dirtyHtml); ``` +## Array Config Files + +Named sanitizers can also be registered with Symfony-style array config files in `config/craft/sanitizers/`. The file name becomes the sanitizer name. + +```php + true, + 'block_elements' => ['h1'], +]; +``` + +Use it like any other named sanitizer: + +```twig +{{ body|sanitize('no-headings') }} +``` + ## Customizing the Default Sanitizer Use `defaults()` to modify Craft's default `HtmlSanitizerConfig`. @@ -162,4 +182,4 @@ For new code: - prefer the `HtmlSanitizers` service or facade for application code - prefer `|sanitize` in Twig -- define named sanitizers in service providers instead of config files when possible +- define named sanitizers in service providers when they need custom PHP logic diff --git a/src/Config/ConfigServiceProvider.php b/src/Config/ConfigServiceProvider.php index 281a1d4103d..e822ab7d48c 100644 --- a/src/Config/ConfigServiceProvider.php +++ b/src/Config/ConfigServiceProvider.php @@ -6,10 +6,12 @@ use CraftCms\Aliases\Aliases; use CraftCms\Cms\Support\Env; +use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; use CraftCms\Cms\Support\Typecast; use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\File; use Illuminate\Support\ServiceProvider; use Override; use Throwable; @@ -45,6 +47,7 @@ public function boot(): void { $this->bootPublishables(); $this->loadGeneralConfig(); + $this->loadHtmlSanitizers(); } private function loadEnvironmentVariablesWhenConfigIsCached(): void @@ -114,4 +117,21 @@ private function loadGeneralConfig(): void Aliases::set($name, $value); } } + + private function loadHtmlSanitizers(): void + { + $path = config_path('craft/sanitizers'); + + if (! File::isDirectory($path)) { + return; + } + + $sanitizers = $this->app->make(HtmlSanitizers::class); + + foreach (File::files($path) as $file) { + if ($file->getExtension() === 'php') { + $sanitizers->register($file->getFilenameWithoutExtension(), require $file->getRealPath()); + } + } + } } diff --git a/src/Support/Facades/HtmlSanitizers.php b/src/Support/Facades/HtmlSanitizers.php index a703a39305a..f8cce976612 100644 --- a/src/Support/Facades/HtmlSanitizers.php +++ b/src/Support/Facades/HtmlSanitizers.php @@ -8,7 +8,7 @@ use Override; /** - * @method static void register(string $name, \Closure|\Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface $definition) + * @method static void register(string $name, array|\Closure|\Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface $definition) * @method static void defaults(\Closure $callback) * @method static bool has(string $name) * @method static \Illuminate\Support\Collection all() diff --git a/src/Support/HtmlSanitizer/HtmlSanitizers.php b/src/Support/HtmlSanitizer/HtmlSanitizers.php index e0607ebdd78..6e4de3c0800 100644 --- a/src/Support/HtmlSanitizer/HtmlSanitizers.php +++ b/src/Support/HtmlSanitizer/HtmlSanitizers.php @@ -10,13 +10,14 @@ use Illuminate\Support\Collection; use InvalidArgumentException; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; #[Singleton] class HtmlSanitizers { - /** @var array */ + /** @var array|Closure|HtmlSanitizerInterface> */ private array $definitions = []; /** @var list */ @@ -30,7 +31,7 @@ public function __construct() $this->definitions['default'] = fn () => new HtmlSanitizer($this->defaultConfig()); } - public function register(string $name, Closure|HtmlSanitizerInterface $definition): void + public function register(string $name, array|Closure|HtmlSanitizerInterface $definition): void { $this->definitions[$name] = $definition; unset($this->resolvedSanitizers[$name]); @@ -108,7 +109,7 @@ public function defaultConfig(): HtmlSanitizerConfig return $config; } - private function resolveDefinition(Closure|HtmlSanitizerInterface $definition): HtmlSanitizerInterface + private function resolveDefinition(array|Closure|HtmlSanitizerInterface $definition): HtmlSanitizerInterface { $resolvedSanitizer = value($definition); @@ -116,6 +117,70 @@ private function resolveDefinition(Closure|HtmlSanitizerInterface $definition): return $resolvedSanitizer; } - throw new InvalidArgumentException('HTML sanitizer definitions must resolve to HtmlSanitizerInterface instances.'); + if (is_array($resolvedSanitizer)) { + return new HtmlSanitizer($this->configFromArray($resolvedSanitizer)); + } + + throw new InvalidArgumentException('HTML sanitizer definitions must resolve to array configs or HtmlSanitizerInterface instances.'); + } + + private function configFromArray(array $settings): HtmlSanitizerConfig + { + $config = new HtmlSanitizerConfig; + + if (array_key_exists('default_action', $settings)) { + $config = $config->defaultAction(HtmlSanitizerAction::from($settings['default_action'])); + } + + foreach (['allow_safe_elements' => 'allowSafeElements', 'allow_static_elements' => 'allowStaticElements'] as $key => $method) { + if ($settings[$key] ?? false) { + $config = $config->$method(); + } + } + + foreach ($settings['allow_elements'] ?? [] as $element => $attributes) { + $config = $config->allowElement($element, $attributes); + } + + foreach (['block_elements' => 'blockElement', 'drop_elements' => 'dropElement'] as $key => $method) { + foreach ($settings[$key] ?? [] as $element) { + $config = $config->$method($element); + } + } + + foreach (['allow_attributes' => 'allowAttribute', 'drop_attributes' => 'dropAttribute'] as $key => $method) { + foreach ($settings[$key] ?? [] as $attribute => $elements) { + $config = $config->$method($attribute, $elements); + } + } + + foreach ($settings['force_attributes'] ?? [] as $element => $attributes) { + foreach ($attributes as $attribute => $value) { + $config = $config->forceAttribute($element, $attribute, $value); + } + } + + foreach ([ + 'force_https_urls' => 'forceHttpsUrls', + 'allowed_link_schemes' => 'allowLinkSchemes', + 'allowed_link_hosts' => 'allowLinkHosts', + 'allow_relative_links' => 'allowRelativeLinks', + 'allowed_media_schemes' => 'allowMediaSchemes', + 'allowed_media_hosts' => 'allowMediaHosts', + 'allow_relative_medias' => 'allowRelativeMedias', + 'max_input_length' => 'withMaxInputLength', + ] as $key => $method) { + if (array_key_exists($key, $settings)) { + $config = $config->$method($settings[$key]); + } + } + + foreach (['with_attribute_sanitizers' => 'withAttributeSanitizer', 'without_attribute_sanitizers' => 'withoutAttributeSanitizer'] as $key => $method) { + foreach ($settings[$key] ?? [] as $sanitizer) { + $config = $config->$method(is_string($sanitizer) ? app($sanitizer) : $sanitizer); + } + } + + return $config; } } diff --git a/tests/Unit/Support/HtmlSanitizer/HtmlSanitizersTest.php b/tests/Unit/Support/HtmlSanitizer/HtmlSanitizersTest.php index a920dfb3e3e..b278f3c6814 100644 --- a/tests/Unit/Support/HtmlSanitizer/HtmlSanitizersTest.php +++ b/tests/Unit/Support/HtmlSanitizer/HtmlSanitizersTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use CraftCms\Cms\Config\ConfigServiceProvider; use CraftCms\Cms\Support\Facades\HtmlSanitizers as HtmlSanitizersFacade; use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\File; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; @@ -13,6 +15,10 @@ $this->sanitizers = app(HtmlSanitizers::class); }); +afterEach(function () { + File::deleteDirectory(config_path('craft/sanitizers')); +}); + test('default sanitizer removes unknown attributes and keeps craft additions', function () { $sanitized = $this->sanitizers->sanitize('
Link'); @@ -77,6 +83,25 @@ expect($sanitized)->toMatchSnapshot(); }); +test('array config files register named sanitizers', function () { + File::ensureDirectoryExists(config_path('craft/sanitizers')); + File::put(config_path('craft/sanitizers/no-headings.php'), <<<'PHP' + true, + 'block_elements' => ['h1'], +]; +PHP); + + app()->forgetInstance(HtmlSanitizers::class); + app(ConfigServiceProvider::class, ['app' => app()])->boot(); + + $sanitized = app(HtmlSanitizers::class)->sanitize('

Title

Body

', 'no-headings'); + + expect($sanitized)->toBe('Title

Body

'); +}); + test('facade resolves the sanitizer service', function () { expect(HtmlSanitizersFacade::sanitizer())->toBeInstanceOf(HtmlSanitizerInterface::class); expect(HtmlSanitizersFacade::defaultConfig())->toBeInstanceOf(HtmlSanitizerConfig::class);