From 46e792bc6d79d3acc2bb62471aef5615153fe5ca Mon Sep 17 00:00:00 2001 From: jerremyng Date: Sun, 4 Jan 2026 08:15:09 +0000 Subject: [PATCH] add HTML rendering on config modal with tests Models/Plugin will now sanitize "description" and "help text" before loading. This allows HTML from these fields to be rendered safely. Sanitization is done using Purify library for completeness (new dependency). A test suite of simple xss attacks is also added. --- app/Models/Plugin.php | 24 ++ composer.json | 1 + composer.lock | 129 ++++++- resources/css/app.css | 4 + .../views/livewire/plugins/recipe.blade.php | 356 ++++++++++-------- tests/Unit/Models/PluginTest.php | 114 +++++- 6 files changed, 470 insertions(+), 158 deletions(-) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 9132d6c..6f5d88b 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -62,6 +62,11 @@ class Plugin extends Model $model->current_image = null; } }); + + // Sanitize configuration template on save + static::saving(function ($model): void { + $model->sanitizeTemplate(); + }); } public function user() @@ -69,6 +74,25 @@ class Plugin extends Model return $this->belongsTo(User::class); } + // sanitize configuration template descriptions and help texts (since they allow HTML rendering) + protected function sanitizeTemplate(): void + { + $template = $this->configuration_template; + + if (isset($template['custom_fields']) && is_array($template['custom_fields'])) { + foreach ($template['custom_fields'] as &$field) { + if (isset($field['description'])) { + $field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']); + } + if (isset($field['help_text'])) { + $field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']); + } + } + + $this->configuration_template = $template; + } + } + public function hasMissingRequiredConfigurationFields(): bool { if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) { diff --git a/composer.json b/composer.json index f801679..0ced4da 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "livewire/volt": "^1.7", "om/icalparser": "^3.2", "spatie/browsershot": "^5.0", + "stevebauman/purify": "^6.3", "symfony/yaml": "^7.3", "wnx/sidecar-browsershot": "^2.6" }, diff --git a/composer.lock b/composer.lock index b9e0495..9767a0d 100644 --- a/composer.lock +++ b/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": "4d958d48655a5ad9e3de6b4a9fb52b0a", + "content-hash": "25c2a1a4a2f2594adefe25ddb6a072fb", "packages": [ { "name": "aws/aws-crt-php", @@ -814,6 +814,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "firebase/php-jwt", "version": "v6.11.1", @@ -4947,6 +5008,72 @@ ], "time": "2025-01-13T13:04:43+00:00" }, + { + "name": "stevebauman/purify", + "version": "v6.3.1", + "source": { + "type": "git", + "url": "https://github.com/stevebauman/purify.git", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.17", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": ">=7.4" + }, + "require-dev": { + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Purify": "Stevebauman\\Purify\\Facades\\Purify" + }, + "providers": [ + "Stevebauman\\Purify\\PurifyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Stevebauman\\Purify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com" + } + ], + "description": "An HTML Purifier / Sanitizer for Laravel", + "keywords": [ + "Purifier", + "clean", + "cleaner", + "html", + "laravel", + "purification", + "purify" + ], + "support": { + "issues": "https://github.com/stevebauman/purify/issues", + "source": "https://github.com/stevebauman/purify/tree/v6.3.1" + }, + "time": "2025-05-21T16:53:09+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", diff --git a/resources/css/app.css b/resources/css/app.css index 46b9ca1..30cb7a1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -59,6 +59,10 @@ @apply !mb-0 !leading-tight; } +[data-flux-description] a { + @apply text-accent underline hover:opacity-80; +} + input:focus[data-flux-control], textarea:focus[data-flux-control], select:focus[data-flux-control] { diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 4be96cc..e8ab799 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -264,7 +264,7 @@ new class extends Component { $fieldKey = $field['keyname']; if (isset($this->configuration[$fieldKey])) { $value = $this->configuration[$fieldKey]; - + // For code fields, if the value is a JSON string and the original was an array, decode it if ($field['field_type'] === 'code' && is_string($value)) { $decoded = json_decode($value, true); @@ -274,7 +274,7 @@ new class extends Component { $value = $decoded; } } - + $configurationValues[$fieldKey] = $value; } } @@ -639,7 +639,14 @@ HTML; @php $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); - + # These are sanitized at PluginImportService when imported, safe to render HTML + $safeDescription = $field['description'] ?? ''; + $safeHelp = $field['help_text'] ?? ''; + + //Important: Sanitize with Purify to prevent XSS attacks + // $safeDescription = Stevebauman\Purify\Facades\Purify::clean($field['description'] ?? ''); + // $safeHelp = Stevebauman\Purify\Facades\Purify::clean($field['help_text'] ?? ''); + // For code fields, if the value is an array, JSON encode it if ($field['field_type'] === 'code' && is_array($rawValue)) { $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -657,176 +664,211 @@ HTML; @endif @if($field['field_type'] === 'string' || $field['field_type'] === 'url') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'text') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'code') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'password') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'copyable') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'time_zone') - - - @foreach(timezone_identifiers_list() as $timezone) - - @endforeach - + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @foreach(timezone_identifiers_list() as $timezone) + + @endforeach + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'number') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'boolean') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'date') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'time') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'select') @if(isset($field['multiple']) && $field['multiple'] === true) - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - + + {{ $field['name'] }} + {!! $safeDescription !!} + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + @else + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + @endif + + @elseif($field['field_type'] === 'xhrSelect') + + {{ $field['name'] }} + {!! $safeDescription !!} - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - + @endif @endforeach @endif - @endif - @elseif($field['field_type'] === 'xhrSelect') - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'xhrSelectSearch')
{{ $field['name'] }} - {{ $field['description'] ?? '' }} + {!! $safeDescription !!} - {{ $field['help_text'] ?? '' }} + {!! $safeHelp !!} @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) @elseif($field['field_type'] === 'multi_string') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + @else Field type "{{ $field['field_type'] }}" not yet supported @endif diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index cf8ea97..49d3f2e 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -4,8 +4,12 @@ use App\Models\Plugin; use App\Models\User; use Carbon\Carbon; use Illuminate\Support\Facades\Http; +use Livewire\Volt\Volt; +use Illuminate\Foundation\Testing\RefreshDatabase; -uses(Illuminate\Foundation\Testing\RefreshDatabase::class); +use Tests\TestCase; + +uses(TestCase::class,RefreshDatabase::class); test('plugin has required attributes', function (): void { $plugin = Plugin::factory()->create([ @@ -679,3 +683,111 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context ->toContain('America/Chicago') ->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds) }); + + +/** + * Plugin security: XSS Payload Dataset + * [Input, Expected to See, Dangerous parts that must be Missing] + */ +dataset('xss_vectors', [ + 'standard_script' => [ + 'Safe ', + 'Safe', + ['', + 'Unclosed tag', + ['