From 07e60698816259a5dfd92135396d6bea334ad8b2 Mon Sep 17 00:00:00 2001 From: antoine Date: Wed, 6 Aug 2025 20:17:37 +0200 Subject: [PATCH 01/10] Sanitize text fields --- composer.json | 1 + demo/composer.json | 1 + demo/composer.lock | 77 ++++++++++++- docs/guide/form-fields/editor.md | 12 +++ docs/guide/form-fields/text.md | 3 + docs/guide/form-fields/textarea.md | 5 +- .../Fields/Formatters/EditorFormatter.php | 8 +- src/Form/Fields/Formatters/TextFormatter.php | 9 +- src/Form/Fields/SharpFormEditorField.php | 17 +++ src/Form/Fields/SharpFormTextField.php | 2 + src/Form/Fields/SharpFormTextareaField.php | 2 + .../SharpFormFieldWithHtmlSanitization.php | 20 ++++ .../Formatters/FormatsSanitizedValue.php | 101 ++++++++++++++++++ .../Fields/Formatters/EditorFormatterTest.php | 57 ++++++++++ .../Fields/Formatters/TextFormatterTest.php | 22 ++++ .../Formatters/TextareaFormatterTest.php | 22 ++++ 16 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 src/Form/Fields/Utils/SharpFormFieldWithHtmlSanitization.php create mode 100644 src/Utils/Fields/Formatters/FormatsSanitizedValue.php diff --git a/composer.json b/composer.json index 8b5cf8e5f..c101d0e4c 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "league/commonmark": "^2.4", "masterminds/html5": "^2.8", "spatie/image-optimizer": "^1.6", + "symfony/html-sanitizer": "^7.3", "tightenco/ziggy": "^2.0" }, "require-dev": { diff --git a/demo/composer.json b/demo/composer.json index a63bdff04..6db860adf 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -16,6 +16,7 @@ "pragmarx/google2fa": "^8.0", "spatie/image-optimizer": "^1.6", "spatie/laravel-translatable": "^6.0", + "symfony/html-sanitizer": "^7.3", "technikermathe/blade-lucide-icons": "^3.98", "tightenco/ziggy": "^1.8" }, diff --git a/demo/composer.lock b/demo/composer.lock index 72ab95e1b..2917dc7d3 100644 --- a/demo/composer.lock +++ b/demo/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c48652e31ba9f176b3c362067dda8c32", + "content-hash": "66586818240ac76c25b23e9621b8c381", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4810,6 +4810,79 @@ ], "time": "2024-12-30T19:00:17+00:00" }, + { + "name": "symfony/html-sanitizer", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "3388e208450fcac57d24aef4d5ae41037b663630" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/3388e208450fcac57d24aef4d5ae41037b663630", + "reference": "3388e208450fcac57d24aef4d5ae41037b663630", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "masterminds/html5": "^2.7.2", + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:29:33+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.2.6", @@ -10513,7 +10586,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.4" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/docs/guide/form-fields/editor.md b/docs/guide/form-fields/editor.md index 241b6b9e7..40fe089b7 100644 --- a/docs/guide/form-fields/editor.md +++ b/docs/guide/form-fields/editor.md @@ -55,6 +55,10 @@ SharpFormEditorField::make("description") ]); ``` +::: warning +HTML included using RAW_HTML button is not sanitized. +::: + If you have editor embeds you can add them to the toolbar alongside other buttons (instead of the embeds dropdown) : ```php @@ -91,6 +95,10 @@ Unset the max character count. Display a character count in the status bar. Default is false. +### `shouldSanitizeHtml(bool $sanitize = true)` + +Toggle HTML sanitization (enabled by default). See [security](#security). + ## Embed images and files in content The Editor field can embed images or regular files. To use this feature, you must first allow the field to handle uploads: @@ -245,6 +253,10 @@ This method expects an array of embeds that could be inserted in the content, de The [documentation on how to write an Embed class is available here](../form-editor-embeds.md). +## Security + +Editor content is sanitized by default before storing the data (to prevent XSS attack when displaying HTML content). To disable sanitizing you can call `->shouldSanitizeHtml(false)`. + ## Formatter - `toFront`: expects a string; will extract embedded files for the front. diff --git a/docs/guide/form-fields/text.md b/docs/guide/form-fields/text.md index d8ede24e8..51c3f2620 100644 --- a/docs/guide/form-fields/text.md +++ b/docs/guide/form-fields/text.md @@ -32,6 +32,9 @@ Set a max character count. Unset the max character count. +### `shouldSanitizeHtml()` + +Enable HTML sanitization (to prevent XSS attacks if this field data is used as raw HTML). ## Formatter diff --git a/docs/guide/form-fields/textarea.md b/docs/guide/form-fields/textarea.md index 49d2f83a8..aed77ddf1 100644 --- a/docs/guide/form-fields/textarea.md +++ b/docs/guide/form-fields/textarea.md @@ -16,8 +16,11 @@ Set a max character count. Unset the max character count. +### `shouldSanitizeHtml()` + +Enable HTML sanitization (to prevent XSS attacks if this field data is used as raw HTML). ## Formatter - `toFront`: expect a string. -- `fromFront`: returns a string. \ No newline at end of file +- `fromFront`: returns a string. diff --git a/src/Form/Fields/Formatters/EditorFormatter.php b/src/Form/Fields/Formatters/EditorFormatter.php index 510a0624e..952369223 100644 --- a/src/Form/Fields/Formatters/EditorFormatter.php +++ b/src/Form/Fields/Formatters/EditorFormatter.php @@ -5,10 +5,13 @@ use Code16\Sharp\Exceptions\Form\SharpFormFieldDataException; use Code16\Sharp\Form\Fields\SharpFormEditorField; use Code16\Sharp\Form\Fields\SharpFormField; +use Code16\Sharp\Utils\Fields\Formatters\FormatsSanitizedValue; use Illuminate\Support\Collection; class EditorFormatter extends SharpFieldFormatter implements FormatsAfterUpdate { + use FormatsSanitizedValue; + /** * @param SharpFormEditorField $field * @@ -48,7 +51,10 @@ public function fromFront(SharpFormField $field, string $attribute, $value) $text = $this->maybeLocalized( $field, $value['text'] ?? null, - fn (string $content) => preg_replace('/\R/u', "\n", $content) + fn (string $content) => $this->sanitizeHtmlIfNeeded( + $field, + preg_replace('/\R/u', "\n", $content) + ) ); $text = $this->editorUploadsFormatter()->fromFront($field, $attribute, [...$value, 'text' => $text]); $text = $this->editorEmbedsFormatter()->fromFront($field, $attribute, [...$value, 'text' => $text]); diff --git a/src/Form/Fields/Formatters/TextFormatter.php b/src/Form/Fields/Formatters/TextFormatter.php index fb468e79c..fdf353cbe 100644 --- a/src/Form/Fields/Formatters/TextFormatter.php +++ b/src/Form/Fields/Formatters/TextFormatter.php @@ -5,9 +5,12 @@ use Code16\Sharp\Exceptions\Form\SharpFormFieldDataException; use Code16\Sharp\Form\Fields\SharpFormField; use Code16\Sharp\Form\Fields\SharpFormTextField; +use Code16\Sharp\Utils\Fields\Formatters\FormatsSanitizedValue; class TextFormatter extends AbstractSimpleFormatter { + use FormatsSanitizedValue; + /** * @param SharpFormTextField $field * @@ -25,6 +28,10 @@ public function toFront(SharpFormField $field, $value) */ public function fromFront(SharpFormField $field, string $attribute, $value) { - return $this->maybeLocalized($field, $value); + return $this->maybeLocalized( + $field, + $value, + fn (string $content) => $this->sanitizeHtmlIfNeeded($field, $content) + ); } } diff --git a/src/Form/Fields/SharpFormEditorField.php b/src/Form/Fields/SharpFormEditorField.php index 1cd160a02..f18d6f15d 100644 --- a/src/Form/Fields/SharpFormEditorField.php +++ b/src/Form/Fields/SharpFormEditorField.php @@ -7,6 +7,8 @@ use Code16\Sharp\Form\Fields\Editor\Uploads\FormEditorUploadForm; use Code16\Sharp\Form\Fields\Editor\Uploads\SharpFormEditorUpload; use Code16\Sharp\Form\Fields\Formatters\EditorFormatter; +use Code16\Sharp\Form\Fields\Formatters\SharpFieldFormatter; +use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithHtmlSanitization; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithMaxLength; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithPlaceholder; use Code16\Sharp\Utils\Fields\IsSharpFieldWithEmbeds; @@ -18,6 +20,7 @@ class SharpFormEditorField extends SharpFormField implements IsSharpFieldWithEmb { use SharpFieldWithEmbeds; use SharpFieldWithLocalization; + use SharpFormFieldWithHtmlSanitization; use SharpFormFieldWithMaxLength { setMaxLength as protected parentSetMaxLength; } @@ -62,6 +65,12 @@ class SharpFormEditorField extends SharpFormField implements IsSharpFieldWithEmb protected bool $withoutParagraphs = false; protected bool $showCharacterCount = false; + protected function __construct(string $key, string $type, ?SharpFieldFormatter $formatter = null) + { + parent::__construct($key, $type, $formatter); + $this->sanitize = true; + } + public static function make(string $key): self { return new static($key, static::FIELD_TYPE, new EditorFormatter()); @@ -156,6 +165,14 @@ protected function innerComponentUploadsConfiguration(): ?array ]; } + /** + * @internal + */ + public function getToolbar(): array + { + return $this->toolbar; + } + protected function toolbarArray(): ?array { if (! $this->showToolbar) { diff --git a/src/Form/Fields/SharpFormTextField.php b/src/Form/Fields/SharpFormTextField.php index 528cc8937..cc2fe99c6 100644 --- a/src/Form/Fields/SharpFormTextField.php +++ b/src/Form/Fields/SharpFormTextField.php @@ -3,6 +3,7 @@ namespace Code16\Sharp\Form\Fields; use Code16\Sharp\Form\Fields\Formatters\TextFormatter; +use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithHtmlSanitization; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithMaxLength; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithPlaceholder; use Code16\Sharp\Utils\Fields\IsSharpFieldWithLocalization; @@ -11,6 +12,7 @@ class SharpFormTextField extends SharpFormField implements IsSharpFieldWithLocalization { use SharpFieldWithLocalization; + use SharpFormFieldWithHtmlSanitization; use SharpFormFieldWithMaxLength; use SharpFormFieldWithPlaceholder; diff --git a/src/Form/Fields/SharpFormTextareaField.php b/src/Form/Fields/SharpFormTextareaField.php index abf48152a..1097c6634 100644 --- a/src/Form/Fields/SharpFormTextareaField.php +++ b/src/Form/Fields/SharpFormTextareaField.php @@ -3,6 +3,7 @@ namespace Code16\Sharp\Form\Fields; use Code16\Sharp\Form\Fields\Formatters\TextareaFormatter; +use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithHtmlSanitization; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithMaxLength; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithPlaceholder; use Code16\Sharp\Utils\Fields\IsSharpFieldWithLocalization; @@ -11,6 +12,7 @@ class SharpFormTextareaField extends SharpFormField implements IsSharpFieldWithLocalization { use SharpFieldWithLocalization; + use SharpFormFieldWithHtmlSanitization; use SharpFormFieldWithMaxLength; use SharpFormFieldWithPlaceholder; diff --git a/src/Form/Fields/Utils/SharpFormFieldWithHtmlSanitization.php b/src/Form/Fields/Utils/SharpFormFieldWithHtmlSanitization.php new file mode 100644 index 000000000..e6e2455da --- /dev/null +++ b/src/Form/Fields/Utils/SharpFormFieldWithHtmlSanitization.php @@ -0,0 +1,20 @@ +sanitize = $sanitize; + + return $this; + } + + public function isSanitizingHtml(): bool + { + return $this->sanitize; + } +} diff --git a/src/Utils/Fields/Formatters/FormatsSanitizedValue.php b/src/Utils/Fields/Formatters/FormatsSanitizedValue.php new file mode 100644 index 000000000..dc83aa98e --- /dev/null +++ b/src/Utils/Fields/Formatters/FormatsSanitizedValue.php @@ -0,0 +1,101 @@ +isSanitizingHtml()) { + return $value; + } + + $config = (new HtmlSanitizerConfig()) + ->allowSafeElements() + ->allowElement('iframe') + ->allowRelativeLinks() + ->allowRelativeMedias() + ->allowElement('div', ['data-encoded-content']) + ->allowAttribute('class', allowedElements: '*') + ->allowAttribute('style', allowedElements: '*') + ->withMaxInputLength(500000); + + if ($field instanceof SharpFormEditorField) { + $encoded = $this->encodeEmbedsAndRawHtml($field, $value); + $sanitized = (new HtmlSanitizer($config))->sanitize($encoded); + + return $this->decodeEmbedsAndRawHtml($field, $sanitized); + } + + return (new HtmlSanitizer($config))->sanitize($value); + } + + protected function isEncodingNeeded(SharpFormEditorField $field): bool + { + return count($field->embeds()) + || $field->uploadsConfig() + || in_array(SharpFormEditorField::RAW_HTML, $field->getToolbar()); + } + + protected function encodeEmbedsAndRawHtml(SharpFormEditorField $field, string $value): string + { + if (! $this->isEncodingNeeded($field)) { + return $value; + } + + $fragment = (new HTML5())->loadHTMLFragment($value); + $embedTags = $field->embeds()->map(fn (SharpFormEditorEmbed $embed) => $embed->tagName())->all(); + + for ($i = 0; $i < $fragment->childNodes->length; $i++) { + $node = $fragment->childNodes->item($i); + if ($node instanceof DOMElement + && (in_array($node->tagName, $embedTags) + || str_starts_with($node->tagName, 'x-') + || $node->hasAttribute('data-html-content')) + ) { + $replacement = $node->ownerDocument->createElement('div'); + $replacement->setAttribute( + 'data-encoded-content', + htmlspecialchars((new HTML5())->saveHTML($node), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + ); + $node->parentNode->replaceChild($replacement, $node); + } + } + + return (new HTML5())->saveHTML($fragment->childNodes); + } + + protected function decodeEmbedsAndRawHtml(SharpFormEditorField $field, string $value): string + { + if (! $this->isEncodingNeeded($field)) { + return $value; + } + + $fragment = (new HTML5())->loadHTMLFragment($value); + + for ($i = 0; $i < $fragment->childNodes->length; $i++) { + $node = $fragment->childNodes->item($i); + if ($node instanceof DOMElement && $node->hasAttribute('data-encoded-content')) { + $replacement = (new HTML5())->loadHTMLFragment( + htmlspecialchars_decode($node->getAttribute('data-encoded-content')), + ['target_document' => $node->ownerDocument] + )->childNodes->item(0); + + $node->parentNode->replaceChild($replacement, $node); + } + } + + return (new HTML5())->saveHTML($fragment->childNodes); + } +} diff --git a/tests/Unit/Form/Fields/Formatters/EditorFormatterTest.php b/tests/Unit/Form/Fields/Formatters/EditorFormatterTest.php index f6af7145c..90d87087b 100644 --- a/tests/Unit/Form/Fields/Formatters/EditorFormatterTest.php +++ b/tests/Unit/Form/Fields/Formatters/EditorFormatterTest.php @@ -351,3 +351,60 @@ ), ); }); + +it('sanitizes HTML content from front by default', function () { + $value = <<<'HTML' + This is unwanted: + 1_ + 2_ + This is wanted: + 1_ + 2_ + 3_
+ HTML; + + $expected = <<<'HTML' + This is unwanted: + 1_ + 2_ + This is wanted: + 1_ + 2_ + 3_
+ HTML; + + expect( + (new EditorFormatter())->fromFront( + SharpFormEditorField::make('md') + ->allowEmbeds([EditorFormatterTestEmbed::class]) + ->allowUploads(SharpFormEditorUpload::make()), + 'attribute', + [ + 'text' => $value, + 'embeds' => [ + (new EditorFormatterTestEmbed())->key() => [ + '0' => [], + ], + ], + 'uploads' => [ + '0' => [ + 'file' => [], + ], + ], + ], + ) + )->toEqual($expected); +}); + +it('does not sanitize HTML content from front when disabled', function () { + expect( + (new EditorFormatter())->fromFront( + SharpFormEditorField::make('md') + ->shouldSanitizeHtml(false), + 'attribute', + [ + 'text' => '', + ], + ) + )->toEqual(''); +}); diff --git a/tests/Unit/Form/Fields/Formatters/TextFormatterTest.php b/tests/Unit/Form/Fields/Formatters/TextFormatterTest.php index 6902f1c33..bdaa89496 100644 --- a/tests/Unit/Form/Fields/Formatters/TextFormatterTest.php +++ b/tests/Unit/Form/Fields/Formatters/TextFormatterTest.php @@ -69,3 +69,25 @@ ) )->toEqual(['fr' => null, 'en' => null, 'es' => null]); }); + +it('sanitizes value from front if configured', function () { + expect( + (new TextFormatter()) + ->fromFront( + SharpFormTextField::make('text'), + 'attribute', + '' + ) + ) + ->toEqual(''); + + expect( + (new TextFormatter()) + ->fromFront( + SharpFormTextField::make('text')->shouldSanitizeHtml(), + 'attribute', + '' + ) + ) + ->toEqual(''); +}); diff --git a/tests/Unit/Form/Fields/Formatters/TextareaFormatterTest.php b/tests/Unit/Form/Fields/Formatters/TextareaFormatterTest.php index 074ddbdd6..61c830437 100644 --- a/tests/Unit/Form/Fields/Formatters/TextareaFormatterTest.php +++ b/tests/Unit/Form/Fields/Formatters/TextareaFormatterTest.php @@ -58,3 +58,25 @@ ) )->toEqual($value); }); + +it('sanitizes value from front if configured', function () { + expect( + (new TextareaFormatter()) + ->fromFront( + SharpFormTextareaField::make('text'), + 'attribute', + '' + ) + ) + ->toEqual(''); + + expect( + (new TextareaFormatter()) + ->fromFront( + SharpFormTextareaField::make('text')->shouldSanitizeHtml(), + 'attribute', + '' + ) + ) + ->toEqual(''); +}); From 8f1da594c42c1b9c5885069c9b8734f8b25bba63 Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 7 Aug 2025 12:47:08 +0200 Subject: [PATCH 02/10] Use arrayable for entity list fields --- src/EntityList/Fields/EntityListBadgeField.php | 6 ++++-- src/EntityList/Fields/EntityListField.php | 6 ++++-- src/EntityList/Fields/EntityListFieldsContainer.php | 3 +-- src/EntityList/Fields/EntityListStateField.php | 6 ++++-- src/EntityList/Fields/IsEntityListField.php | 2 -- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/EntityList/Fields/EntityListBadgeField.php b/src/EntityList/Fields/EntityListBadgeField.php index 3f93e610f..44eca60fa 100644 --- a/src/EntityList/Fields/EntityListBadgeField.php +++ b/src/EntityList/Fields/EntityListBadgeField.php @@ -2,7 +2,9 @@ namespace Code16\Sharp\EntityList\Fields; -class EntityListBadgeField implements IsEntityListField +use Illuminate\Contracts\Support\Arrayable; + +class EntityListBadgeField implements Arrayable, IsEntityListField { use HasCommonEntityListFieldAttributes; @@ -25,7 +27,7 @@ public function setTooltip(string $tooltip): self return $this; } - public function getFieldProperties(): array + public function toArray(): array { return [ 'type' => 'badge', diff --git a/src/EntityList/Fields/EntityListField.php b/src/EntityList/Fields/EntityListField.php index 2106568e9..0b2dc071a 100644 --- a/src/EntityList/Fields/EntityListField.php +++ b/src/EntityList/Fields/EntityListField.php @@ -2,7 +2,9 @@ namespace Code16\Sharp\EntityList\Fields; -class EntityListField implements IsEntityListField +use Illuminate\Contracts\Support\Arrayable; + +class EntityListField implements Arrayable, IsEntityListField { use HasCommonEntityListFieldAttributes; @@ -41,7 +43,7 @@ public function setWidthOnSmallScreensFill(): self return $this; } - public function getFieldProperties(): array + public function toArray(): array { return [ 'type' => 'text', diff --git a/src/EntityList/Fields/EntityListFieldsContainer.php b/src/EntityList/Fields/EntityListFieldsContainer.php index 22fbcb03b..6b31d5221 100644 --- a/src/EntityList/Fields/EntityListFieldsContainer.php +++ b/src/EntityList/Fields/EntityListFieldsContainer.php @@ -75,7 +75,6 @@ final public function getFields(bool $shouldHaveStateField = false): Collection $this->fields[] = EntityListStateField::make(); } - return collect($this->fields) - ->map(fn (IsEntityListField $field) => $field->getFieldProperties()); + return collect($this->fields); } } diff --git a/src/EntityList/Fields/EntityListStateField.php b/src/EntityList/Fields/EntityListStateField.php index 40d1724c6..31875d892 100644 --- a/src/EntityList/Fields/EntityListStateField.php +++ b/src/EntityList/Fields/EntityListStateField.php @@ -2,7 +2,9 @@ namespace Code16\Sharp\EntityList\Fields; -class EntityListStateField implements IsEntityListField +use Illuminate\Contracts\Support\Arrayable; + +class EntityListStateField implements Arrayable, IsEntityListField { use HasCommonEntityListFieldAttributes; @@ -13,7 +15,7 @@ public static function make(): static return new static(); } - public function getFieldProperties(): array + public function toArray(): array { return [ 'type' => 'state', diff --git a/src/EntityList/Fields/IsEntityListField.php b/src/EntityList/Fields/IsEntityListField.php index 55f35ccfc..82be9220d 100644 --- a/src/EntityList/Fields/IsEntityListField.php +++ b/src/EntityList/Fields/IsEntityListField.php @@ -4,8 +4,6 @@ interface IsEntityListField { - public function getFieldProperties(): array; - public function setLabel(string $label): self; public function setSortable(bool $sortable = true): self; From cd4a0ab7e033089ed404001c14c1fd92ff52d084 Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 7 Aug 2025 12:47:53 +0200 Subject: [PATCH 03/10] Move sanitization in the same place --- .../Fields/Formatters/EditorFormatter.php | 2 +- src/Form/Fields/Formatters/TextFormatter.php | 2 +- src/Form/Fields/SharpFormEditorField.php | 7 ++-- src/Form/Fields/SharpFormTextField.php | 7 ++-- src/Form/Fields/SharpFormTextareaField.php | 7 ++-- .../FormatsSanitizedValue.php | 39 +++++++++++-------- .../IsSharpFieldWithHtmlSanitization.php | 8 ++++ .../SharpFieldWithHtmlSanitization.php} | 4 +- 8 files changed, 46 insertions(+), 30 deletions(-) rename src/Utils/{Fields/Formatters => Sanitization}/FormatsSanitizedValue.php (79%) create mode 100644 src/Utils/Sanitization/IsSharpFieldWithHtmlSanitization.php rename src/{Form/Fields/Utils/SharpFormFieldWithHtmlSanitization.php => Utils/Sanitization/SharpFieldWithHtmlSanitization.php} (77%) diff --git a/src/Form/Fields/Formatters/EditorFormatter.php b/src/Form/Fields/Formatters/EditorFormatter.php index 952369223..74b86e055 100644 --- a/src/Form/Fields/Formatters/EditorFormatter.php +++ b/src/Form/Fields/Formatters/EditorFormatter.php @@ -5,7 +5,7 @@ use Code16\Sharp\Exceptions\Form\SharpFormFieldDataException; use Code16\Sharp\Form\Fields\SharpFormEditorField; use Code16\Sharp\Form\Fields\SharpFormField; -use Code16\Sharp\Utils\Fields\Formatters\FormatsSanitizedValue; +use Code16\Sharp\Utils\Sanitization\FormatsSanitizedValue; use Illuminate\Support\Collection; class EditorFormatter extends SharpFieldFormatter implements FormatsAfterUpdate diff --git a/src/Form/Fields/Formatters/TextFormatter.php b/src/Form/Fields/Formatters/TextFormatter.php index fdf353cbe..8cd386924 100644 --- a/src/Form/Fields/Formatters/TextFormatter.php +++ b/src/Form/Fields/Formatters/TextFormatter.php @@ -5,7 +5,7 @@ use Code16\Sharp\Exceptions\Form\SharpFormFieldDataException; use Code16\Sharp\Form\Fields\SharpFormField; use Code16\Sharp\Form\Fields\SharpFormTextField; -use Code16\Sharp\Utils\Fields\Formatters\FormatsSanitizedValue; +use Code16\Sharp\Utils\Sanitization\FormatsSanitizedValue; class TextFormatter extends AbstractSimpleFormatter { diff --git a/src/Form/Fields/SharpFormEditorField.php b/src/Form/Fields/SharpFormEditorField.php index f18d6f15d..24d350d90 100644 --- a/src/Form/Fields/SharpFormEditorField.php +++ b/src/Form/Fields/SharpFormEditorField.php @@ -8,19 +8,20 @@ use Code16\Sharp\Form\Fields\Editor\Uploads\SharpFormEditorUpload; use Code16\Sharp\Form\Fields\Formatters\EditorFormatter; use Code16\Sharp\Form\Fields\Formatters\SharpFieldFormatter; -use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithHtmlSanitization; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithMaxLength; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithPlaceholder; use Code16\Sharp\Utils\Fields\IsSharpFieldWithEmbeds; use Code16\Sharp\Utils\Fields\IsSharpFieldWithLocalization; use Code16\Sharp\Utils\Fields\SharpFieldWithEmbeds; use Code16\Sharp\Utils\Fields\SharpFieldWithLocalization; +use Code16\Sharp\Utils\Sanitization\IsSharpFieldWithHtmlSanitization; +use Code16\Sharp\Utils\Sanitization\SharpFieldWithHtmlSanitization; -class SharpFormEditorField extends SharpFormField implements IsSharpFieldWithEmbeds, IsSharpFieldWithLocalization +class SharpFormEditorField extends SharpFormField implements IsSharpFieldWithEmbeds, IsSharpFieldWithHtmlSanitization, IsSharpFieldWithLocalization { use SharpFieldWithEmbeds; + use SharpFieldWithHtmlSanitization; use SharpFieldWithLocalization; - use SharpFormFieldWithHtmlSanitization; use SharpFormFieldWithMaxLength { setMaxLength as protected parentSetMaxLength; } diff --git a/src/Form/Fields/SharpFormTextField.php b/src/Form/Fields/SharpFormTextField.php index cc2fe99c6..59e907f67 100644 --- a/src/Form/Fields/SharpFormTextField.php +++ b/src/Form/Fields/SharpFormTextField.php @@ -3,16 +3,17 @@ namespace Code16\Sharp\Form\Fields; use Code16\Sharp\Form\Fields\Formatters\TextFormatter; -use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithHtmlSanitization; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithMaxLength; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithPlaceholder; use Code16\Sharp\Utils\Fields\IsSharpFieldWithLocalization; use Code16\Sharp\Utils\Fields\SharpFieldWithLocalization; +use Code16\Sharp\Utils\Sanitization\IsSharpFieldWithHtmlSanitization; +use Code16\Sharp\Utils\Sanitization\SharpFieldWithHtmlSanitization; -class SharpFormTextField extends SharpFormField implements IsSharpFieldWithLocalization +class SharpFormTextField extends SharpFormField implements IsSharpFieldWithHtmlSanitization, IsSharpFieldWithLocalization { + use SharpFieldWithHtmlSanitization; use SharpFieldWithLocalization; - use SharpFormFieldWithHtmlSanitization; use SharpFormFieldWithMaxLength; use SharpFormFieldWithPlaceholder; diff --git a/src/Form/Fields/SharpFormTextareaField.php b/src/Form/Fields/SharpFormTextareaField.php index 1097c6634..51f95b6fd 100644 --- a/src/Form/Fields/SharpFormTextareaField.php +++ b/src/Form/Fields/SharpFormTextareaField.php @@ -3,16 +3,17 @@ namespace Code16\Sharp\Form\Fields; use Code16\Sharp\Form\Fields\Formatters\TextareaFormatter; -use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithHtmlSanitization; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithMaxLength; use Code16\Sharp\Form\Fields\Utils\SharpFormFieldWithPlaceholder; use Code16\Sharp\Utils\Fields\IsSharpFieldWithLocalization; use Code16\Sharp\Utils\Fields\SharpFieldWithLocalization; +use Code16\Sharp\Utils\Sanitization\IsSharpFieldWithHtmlSanitization; +use Code16\Sharp\Utils\Sanitization\SharpFieldWithHtmlSanitization; -class SharpFormTextareaField extends SharpFormField implements IsSharpFieldWithLocalization +class SharpFormTextareaField extends SharpFormField implements IsSharpFieldWithHtmlSanitization, IsSharpFieldWithLocalization { + use SharpFieldWithHtmlSanitization; use SharpFieldWithLocalization; - use SharpFormFieldWithHtmlSanitization; use SharpFormFieldWithMaxLength; use SharpFormFieldWithPlaceholder; diff --git a/src/Utils/Fields/Formatters/FormatsSanitizedValue.php b/src/Utils/Sanitization/FormatsSanitizedValue.php similarity index 79% rename from src/Utils/Fields/Formatters/FormatsSanitizedValue.php rename to src/Utils/Sanitization/FormatsSanitizedValue.php index dc83aa98e..d3ac08646 100644 --- a/src/Utils/Fields/Formatters/FormatsSanitizedValue.php +++ b/src/Utils/Sanitization/FormatsSanitizedValue.php @@ -1,11 +1,9 @@ isSanitizingHtml()) { + if (! $value || ! str_contains($value, '<') || ! $field->isSanitizingHtml()) { return $value; } + if ($field instanceof SharpFormEditorField) { + $encoded = $this->encodeEmbedsAndRawHtml($field, $value); + $sanitized = $this->sanitizer()->sanitize($encoded); + + return $this->decodeEmbedsAndRawHtml($field, $sanitized); + } + + return $this->sanitizer()->sanitize($value); + } + + private function sanitizer(): HtmlSanitizer + { $config = (new HtmlSanitizerConfig()) ->allowSafeElements() ->allowElement('iframe') @@ -31,24 +43,17 @@ protected function sanitizeHtmlIfNeeded( ->allowAttribute('style', allowedElements: '*') ->withMaxInputLength(500000); - if ($field instanceof SharpFormEditorField) { - $encoded = $this->encodeEmbedsAndRawHtml($field, $value); - $sanitized = (new HtmlSanitizer($config))->sanitize($encoded); - - return $this->decodeEmbedsAndRawHtml($field, $sanitized); - } - - return (new HtmlSanitizer($config))->sanitize($value); + return $this->sanitizer ??= new HtmlSanitizer($config); } - protected function isEncodingNeeded(SharpFormEditorField $field): bool + private function isEncodingNeeded(SharpFormEditorField $field): bool { return count($field->embeds()) || $field->uploadsConfig() || in_array(SharpFormEditorField::RAW_HTML, $field->getToolbar()); } - protected function encodeEmbedsAndRawHtml(SharpFormEditorField $field, string $value): string + private function encodeEmbedsAndRawHtml(SharpFormEditorField $field, string $value): string { if (! $this->isEncodingNeeded($field)) { return $value; @@ -76,7 +81,7 @@ protected function encodeEmbedsAndRawHtml(SharpFormEditorField $field, string $v return (new HTML5())->saveHTML($fragment->childNodes); } - protected function decodeEmbedsAndRawHtml(SharpFormEditorField $field, string $value): string + private function decodeEmbedsAndRawHtml(SharpFormEditorField $field, string $value): string { if (! $this->isEncodingNeeded($field)) { return $value; diff --git a/src/Utils/Sanitization/IsSharpFieldWithHtmlSanitization.php b/src/Utils/Sanitization/IsSharpFieldWithHtmlSanitization.php new file mode 100644 index 000000000..9a80e4228 --- /dev/null +++ b/src/Utils/Sanitization/IsSharpFieldWithHtmlSanitization.php @@ -0,0 +1,8 @@ + Date: Thu, 7 Aug 2025 14:02:20 +0200 Subject: [PATCH 04/10] Sanitize in the front (EL columns / show title & text field) --- package-lock.json | 31 +++++++++++++++++ package.json | 1 + resources/js/Pages/Show/Show.vue | 7 +++- .../js/entity-list/components/EntityList.vue | 3 +- .../components/fields/text/TextRenderer.vue | 3 +- resources/js/utils/sanitize.ts | 12 +++++++ .../Sanitization/FormatsSanitizedValue.php | 34 ++++++++++--------- 7 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 resources/js/utils/sanitize.ts diff --git a/package-lock.json b/package-lock.json index 8fa206fa1..af8797be8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cropperjs": "^1.5.12", + "dompurify": "^3.2.6", "filesize": "^10.1.0", "flexsearch": "^0.7.43", "leaflet": "^1.9.4", @@ -3829,6 +3830,13 @@ "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz", "integrity": "sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -6981,6 +6989,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", @@ -21640,6 +21657,12 @@ "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz", "integrity": "sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==" }, + "@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -24241,6 +24264,14 @@ "domelementtype": "^2.3.0" } }, + "dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "requires": { + "@types/trusted-types": "^2.0.7" + } + }, "domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", diff --git a/package.json b/package.json index aeb0c8f43..755a6edd2 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cropperjs": "^1.5.12", + "dompurify": "^3.2.6", "filesize": "^10.1.0", "flexsearch": "^0.7.43", "leaflet": "^1.9.4", diff --git a/resources/js/Pages/Show/Show.vue b/resources/js/Pages/Show/Show.vue index 703498790..27e3c57e7 100644 --- a/resources/js/Pages/Show/Show.vue +++ b/resources/js/Pages/Show/Show.vue @@ -44,6 +44,7 @@ import { useEntityListHighlightedItem } from "@/composables/useEntityListHighlightedItem"; import RootCardHeader from "@/components/ui/RootCardHeader.vue"; import StateBadge from "@/components/ui/StateBadge.vue"; + import { sanitize } from "@/utils/sanitize"; const props = defineProps<{ show: ShowData, @@ -179,7 +180,11 @@