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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
> [!IMPORTANT]
> This update contains breaking changes for plugins. See [#19263](https://github.com/craftcms/cms/pull/19263) for details.

- Replaced `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers` with `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager`. HTML sanitizers should now be registered via `CraftCms\Cms\Support\Facades\HtmlSanitizers::extend()` rather than `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers::register()`. ([#19292](https://github.com/craftcms/cms/pull/19292))
- Plugins should no longer define `extra.laravel.providers` in `composer.json`. ([#19263](https://github.com/craftcms/cms/pull/19263))
- Removed automatic plugin trait lifecycle hooks. ([#19263](https://github.com/craftcms/cms/pull/19263))
- Added `CraftCms\Cms\Cp\Components\Button`. ([#19248](https://github.com/craftcms/cms/pull/19248))
Expand Down
138 changes: 43 additions & 95 deletions docs/html-sanitizers.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
# HTML Sanitizers

Craft 6 includes a Laravel-native HTML sanitizer registry built on [Symfony HtmlSanitizer](https://symfony.com/doc/current/html_sanitizer.html).
Craft 6 includes a Laravel-native `HtmlSanitizerManager` built on [Symfony HtmlSanitizer](https://symfony.com/doc/current/html_sanitizer.html). Application code can access it through the plural `HtmlSanitizers` facade.

The `HtmlSanitizers` service lets plugins and apps:
The manager lets plugins and apps:

- sanitize HTML with Craft's default sanitizer
- sanitize HTML with Crafts default sanitizer
- register named sanitizers for project-specific rules
- customize the default sanitizer config
- reuse Craft's default config when defining custom sanitizers
- reuse Crafts default config when defining custom sanitizers

Legacy HtmlPurifier support remains available through the Yii2 adapter layer. The legacy Twig `|purify` filter still uses HtmlPurifier, while the new `|sanitize` filter uses the new sanitizer registry.
Legacy HtmlPurifier support remains available through the Yii2 adapter layer. The legacy Twig `|purify` filter still uses HtmlPurifier, while `|sanitize` uses the sanitizer manager.

## Basic Usage

Use the facade and call `sanitize()`:

```php
<?php

Expand All @@ -23,38 +21,27 @@ use CraftCms\Cms\Support\Facades\HtmlSanitizers;
$cleanHtml = HtmlSanitizers::sanitize($dirtyHtml);
```

This uses Craft's default sanitizer.

## Default Behavior

Craft's default sanitizer starts from Symfony's safe and static element sets, then adds Craft-specific support for:

- relative links and media URLs
- `div[data-oembed-url]`
- `oembed[url]`
- Craft's video embed URL sanitizer

The default sanitizer is intended for general safe rich text output.
Craft’s default sanitizer starts from Symfony’s safe and static element sets, then adds support for relative links and media URLs, `div[data-oembed-url]`, `oembed[url]`, and Craft’s video embed URL sanitizer.

## Registering Named Sanitizers

Plugins and apps can register named sanitizers from a service provider. Registered sanitizers may be concrete `HtmlSanitizerInterface` instances or closures that return one.
Register extensions from a service provider’s `boot()` method:

```php
<?php

namespace App\Providers;

use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers;
use CraftCms\Cms\Support\Facades\HtmlSanitizers;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

class AppServiceProvider extends ServiceProvider
{
public function boot(HtmlSanitizers $sanitizers): void
public function boot(): void
{
$sanitizers->register('links-only', new HtmlSanitizer(
HtmlSanitizers::extend('links-only', new HtmlSanitizer(
(new HtmlSanitizerConfig())
->allowElement('a')
->allowAttribute('href', ['a'])
Expand All @@ -63,29 +50,32 @@ class AppServiceProvider extends ServiceProvider
}
```

Use the registered sanitizer by name:
`extend()` accepts any of these definitions:

```php
<?php
- an array of sanitizer settings
- an `HtmlSanitizerInterface` instance
- a creator closure that returns either an array or an `HtmlSanitizerInterface` instance

use CraftCms\Cms\Support\Facades\HtmlSanitizers;
Laravel binds anonymous creator closures to the manager and passes the service container as their first argument.

$cleanHtml = HtmlSanitizers::sanitize($dirtyHtml, 'links-only');
```php
HtmlSanitizers::extend('links-only', fn () => [
'allow_elements' => [
'a' => ['href'],
],
]);
```

You can also resolve the sanitizer instance directly:
Use a sanitizer by name or resolve it directly:

```php
<?php

use CraftCms\Cms\Support\Facades\HtmlSanitizers;

$cleanHtml = HtmlSanitizers::sanitize($dirtyHtml, 'links-only');
$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.
Named sanitizers can also be defined in `config/craft/sanitizers/`. The file name becomes the sanitizer name and the returned array is loaded through Laravel’s configuration repository, including when configuration is cached.

```php
<?php
Expand All @@ -97,89 +87,47 @@ return [
];
```

Use it like any other named sanitizer:
Config files must return arrays; closures and object instances are not compatible with `config:cache`. Unknown definition keys are ignored.
Comment thread
riasvdv marked this conversation as resolved.

```twig
{{ body|sanitize('no-headings') }}
```

A `default.php` definition replaces Craft’s built-in default sanitizer.

## Customizing the Default Sanitizer

Use `defaults()` to modify Craft's default `HtmlSanitizerConfig`.
Use `defaults()` to transform Crafts default `HtmlSanitizerConfig`. Every callback must return an `HtmlSanitizerConfig`.

```php
<?php

namespace App\Providers;

use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

class AppServiceProvider extends ServiceProvider
{
public function boot(HtmlSanitizers $sanitizers): void
{
$sanitizers->defaults(fn (HtmlSanitizerConfig $config) => $config
->allowAttribute('class', ['p', 'span'])
->allowElement('iframe')
->allowAttribute('src', ['iframe'])
);
}
}
HtmlSanitizers::defaults(static fn (HtmlSanitizerConfig $config) => $config
->allowAttribute('class', ['p', 'span'])
->allowElement('iframe')
->allowAttribute('src', ['iframe'])
);
```

Each callback receives the current default config and may return the same config or a modified one.

The default sanitizer instance is built lazily from `defaultConfig()`, so calling `defaults()` changes what `sanitize($html)` uses by default.

## Reusing the Default Config

If you want a custom named sanitizer that starts from Craft's defaults, call `defaultConfig()` and keep building from there.
To create a named sanitizer starting from Craft’s defaults:

```php
<?php

namespace App\Providers;

use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;

class AppServiceProvider extends ServiceProvider
{
public function boot(HtmlSanitizers $sanitizers): void
{
$config = $sanitizers->defaultConfig()
->allowElement('iframe')
->allowAttribute('src', ['iframe']);

$sanitizers->register('embedded-content', new HtmlSanitizer($config));
}
}
HtmlSanitizers::extend('embedded-content', fn () => new HtmlSanitizer(
HtmlSanitizers::defaultConfig()
->allowElement('iframe')
->allowAttribute('src', ['iframe'])
));
```

This is the recommended way to define custom sanitizers that should stay close to Craft's defaults.

## Twig Filters
## Resolution and Caching

Craft now provides two distinct Twig filters:
Sanitizers are created lazily and cached by name. Register extensions and default callbacks before their sanitizers are first resolved. Replacing a creator or adding a default callback does not change an already-resolved sanitizer. Call `HtmlSanitizers::forgetDrivers()` to clear all resolved instances when an intentional runtime change must take effect.

- `|sanitize` uses the new `HtmlSanitizers` service
Calling `all()` eagerly resolves every registered sanitizer. `getDrivers()` returns only sanitizers that have already been resolved.

Examples:
## Twig Filter

```twig
{{ body|sanitize }}
{{ body|sanitize('links-only') }}
```

You may also pass a concrete sanitizer instance from PHP into a template context and use it directly with `|sanitize`.

## Recommended Direction

For new code:

- prefer the `HtmlSanitizers` service or facade for application code
- prefer `|sanitize` in Twig
- define named sanitizers in service providers when they need custom PHP logic
2 changes: 1 addition & 1 deletion resources/js/common/layouts/AuthBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
.cp-login__powered-by {
display: block;
margin-block-start: calc(70rem / 16);
opacity: 0.92;;
opacity: 0.92;
text-align: center;
&:hover,
Expand Down
21 changes: 11 additions & 10 deletions src/Config/ConfigServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

use CraftCms\Aliases\Aliases;
use CraftCms\Cms\Support\Env;
use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers;
use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager;
use CraftCms\Cms\Support\Typecast;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use Override;

class ConfigServiceProvider extends ServiceProvider
Expand Down Expand Up @@ -128,18 +128,19 @@ private function applyGeneralConfigSetting(GeneralConfig $config, string $settin

private function loadHtmlSanitizers(): void
{
$path = config_path('craft/sanitizers');
$sanitizers = $this->app->make(HtmlSanitizerManager::class);
$definitions = $this->app->make(ConfigRepository::class)->get('craft.sanitizers', []);

if (! File::isDirectory($path)) {
return;
if (! is_array($definitions)) {
throw new InvalidArgumentException('The [craft.sanitizers] configuration must be an array.');
Comment thread
riasvdv marked this conversation as resolved.
}

$sanitizers = $this->app->make(HtmlSanitizers::class);

foreach (File::files($path) as $file) {
if ($file->getExtension() === 'php') {
$sanitizers->register($file->getFilenameWithoutExtension(), require $file->getRealPath());
foreach ($definitions as $name => $definition) {
if (! is_string($name) || ! is_array($definition)) {
throw new InvalidArgumentException('HTML sanitizer configuration definitions must be named arrays.');
}

$sanitizers->extend($name, $definition);
}
}
}
4 changes: 2 additions & 2 deletions src/Field/Data/MarkdownData.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

use CraftCms\Cms\Shared\Contracts\Serializable;
use CraftCms\Cms\Support\Facades\Elements;
use CraftCms\Cms\Support\Facades\HtmlSanitizers;
use CraftCms\Cms\Support\Facades\Markdown as MarkdownFacade;
use CraftCms\Cms\Support\Html;
use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers;
use CraftCms\Cms\Twig\Attributes\AllowedInSandbox;
use CraftCms\Cms\Twig\Contracts\SafeHtml;
use Illuminate\Contracts\Support\Htmlable;
Expand Down Expand Up @@ -64,7 +64,7 @@ public function getHtml(): string
return $html;
}

return app(HtmlSanitizers::class)->sanitize($html, $this->htmlSanitizer);
return HtmlSanitizers::sanitize($html, $this->htmlSanitizer);
}

#[AllowedInSandbox]
Expand Down
4 changes: 2 additions & 2 deletions src/Field/Markdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
use CraftCms\Cms\Markdown\Markdown as MarkdownService;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\Folders;
use CraftCms\Cms\Support\Facades\HtmlSanitizers;
use CraftCms\Cms\Support\Facades\InputNamespace;
use CraftCms\Cms\Support\Facades\Sites;
use CraftCms\Cms\Support\Facades\Volumes;
use CraftCms\Cms\Support\Html;
use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers;
use CraftCms\Cms\Support\Str;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
Expand Down Expand Up @@ -381,7 +381,7 @@ protected function supportedLinkAdvancedFields(): array

private function htmlSanitizerOptions(): Collection
{
return collect(app(HtmlSanitizers::class)->names())
return collect(HtmlSanitizers::names())
->map(fn (string $name) => [
'label' => $name === 'default' ? t('Default') : $name,
'value' => $name,
Expand Down
4 changes: 2 additions & 2 deletions src/Http/Controllers/App/RenderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
use CraftCms\Cms\Field\Markdown as MarkdownField;
use CraftCms\Cms\Markdown\Markdown as MarkdownService;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\HtmlSanitizers;
use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers;
use CraftCms\Cms\Support\Typecast;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -180,7 +180,7 @@ public function markdown(Request $request): JsonResponse
'encode' => ['boolean'],
'inlineOnly' => ['boolean'],
'sanitizeHtml' => ['boolean'],
'htmlSanitizer' => ['nullable', 'string', Rule::in(app(HtmlSanitizers::class)->names())],
'htmlSanitizer' => ['nullable', 'string', Rule::in(HtmlSanitizers::names())],
]);

$encode = $request->boolean('encode');
Expand Down
12 changes: 8 additions & 4 deletions src/Support/Facades/HtmlSanitizers.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,30 @@

namespace CraftCms\Cms\Support\Facades;

use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager;
use Illuminate\Support\Facades\Facade;
use Override;

/**
* @method static void register(string $name, \Closure|\Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface|array $definition)
* @method static void defaults(\Closure $callback)
* @method static \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager extend(string $name, \Closure|\Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface|array $definition)
* @method static \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager defaults(\Closure $callback)
* @method static bool has(string $name)
* @method static array names()
* @method static \Illuminate\Support\Collection all()
* @method static string sanitize(string $html, \Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface|string|null $sanitizer = null)
* @method static \Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface sanitizer(string|null $name = null)
* @method static \Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface driver(string|null $driver = null)
* @method static \Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig defaultConfig()
* @method static array getDrivers()
* @method static \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager forgetDrivers()
*
* @see \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers
* @see HtmlSanitizerManager
*/
class HtmlSanitizers extends Facade
{
#[Override]
protected static function getFacadeAccessor(): string
{
return \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers::class;
return HtmlSanitizerManager::class;
}
}
2 changes: 1 addition & 1 deletion src/Support/Facades/Template.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
* @method static \CraftCms\Cms\View\TemplateManager setContainer(\Illuminate\Contracts\Container\Container $container)
* @method static \CraftCms\Cms\View\TemplateManager forgetDrivers()
*
* @see \CraftCms\Cms\View\TemplateManager
* @see TemplateManager
*/
class Template extends Facade
{
Expand Down
Loading
Loading