Skip to content
Merged
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 CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
22 changes: 21 additions & 1 deletion docs/html-sanitizers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php

// config/craft/sanitizers/no-headings.php
return [
'allow_safe_elements' => 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`.
Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions src/Config/ConfigServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,6 +47,7 @@ public function boot(): void
{
$this->bootPublishables();
$this->loadGeneralConfig();
$this->loadHtmlSanitizers();
}

private function loadEnvironmentVariablesWhenConfigIsCached(): void
Expand Down Expand Up @@ -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());
}
}
}
}
2 changes: 1 addition & 1 deletion src/Support/Facades/HtmlSanitizers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, \Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface> all()
Expand Down
73 changes: 69 additions & 4 deletions src/Support/HtmlSanitizer/HtmlSanitizers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Closure|HtmlSanitizerInterface> */
/** @var array<string, array<string, mixed>|Closure|HtmlSanitizerInterface> */
private array $definitions = [];

/** @var list<Closure> */
Expand All @@ -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]);
Expand Down Expand Up @@ -108,14 +109,78 @@ public function defaultConfig(): HtmlSanitizerConfig
return $config;
}

private function resolveDefinition(Closure|HtmlSanitizerInterface $definition): HtmlSanitizerInterface
private function resolveDefinition(array|Closure|HtmlSanitizerInterface $definition): HtmlSanitizerInterface
{
$resolvedSanitizer = value($definition);

if ($resolvedSanitizer instanceof HtmlSanitizerInterface) {
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;
}
}
25 changes: 25 additions & 0 deletions tests/Unit/Support/HtmlSanitizer/HtmlSanitizersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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('<div data-oembed-url="https://www.youtube.com/watch?v=test" bad="x"></div><oembed url="https://www.youtube.com/watch?v=test"></oembed><a download href="/foo" rel="external custom">Link</a>');

Expand Down Expand Up @@ -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'
<?php

return [
'allow_safe_elements' => true,
'block_elements' => ['h1'],
];
PHP);

app()->forgetInstance(HtmlSanitizers::class);
app(ConfigServiceProvider::class, ['app' => app()])->boot();

$sanitized = app(HtmlSanitizers::class)->sanitize('<h1>Title</h1><p>Body</p>', 'no-headings');

expect($sanitized)->toBe('Title<p>Body</p>');
});

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