diff --git a/app/Casts/CleanHtml.php b/app/Casts/CleanHtml.php new file mode 100644 index 0000000..f8ddb36 --- /dev/null +++ b/app/Casts/CleanHtml.php @@ -0,0 +1,38 @@ + $attributes + */ + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + return $value; + } + + /** + * Prepare the given value for storage. + * + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): mixed + { + if ($value === null) { + return null; + } + + if (! is_string($value)) { + return $value; + } + + return app(HtmlSanitizer::class)->sanitize($value); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php index 2e70a6d..f89296f 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Casts\CleanHtml; use Database\Factories\TaskFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -44,6 +45,7 @@ protected function casts(): array 'column_updated_at' => 'datetime', 'due_date' => 'datetime', 'time_spent_in_columns' => 'array', + 'description' => CleanHtml::class, ]; } diff --git a/app/Models/TaskComment.php b/app/Models/TaskComment.php index 5675ff3..fcaaa56 100644 --- a/app/Models/TaskComment.php +++ b/app/Models/TaskComment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Casts\CleanHtml; use Database\Factories\TaskCommentFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -25,6 +26,18 @@ class TaskComment extends Model 'body', ]; + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'body' => CleanHtml::class, + ]; + } + /** * Get the task that owns the comment. */ diff --git a/app/Providers/HtmlSanitizerProvider.php b/app/Providers/HtmlSanitizerProvider.php new file mode 100644 index 0000000..9f90123 --- /dev/null +++ b/app/Providers/HtmlSanitizerProvider.php @@ -0,0 +1,44 @@ +app->singleton(HtmlSanitizer::class, function () { + $config = (new HtmlSanitizerConfig) + ->allowSafeElements() + ->allowRelativeLinks() + ->allowLinkSchemes(['http', 'https', 'mailto']) + ->allowAttribute('class', '*') + ->allowAttribute('style', '*') + ->allowAttribute('data-id', '*') + ->allowAttribute('data-action', '*') + ->allowAttribute('data-target', '*') + ->allowAttribute('data-task', '*') + ->allowAttribute('data-comment', '*') + ->allowAttribute('id', '*') + ->allowAttribute('title', '*') + ->allowAttribute('alt', '*') + ->allowAttribute('width', '*') + ->allowAttribute('height', '*') + ->allowAttribute('src', ['img']) + ->allowAttribute('target', ['a']) + ->allowAttribute('rel', ['a']); + + return new HtmlSanitizer($config); + }); + + $this->app->alias(HtmlSanitizer::class, 'html.sanitizer'); + } + + public function boot(): void + { + // + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 5ffd769..c443fca 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,8 +2,10 @@ use App\Providers\AppServiceProvider; use App\Providers\FortifyServiceProvider; +use App\Providers\HtmlSanitizerProvider; return [ AppServiceProvider::class, FortifyServiceProvider::class, + HtmlSanitizerProvider::class, ]; diff --git a/composer.json b/composer.json index e362edc..3d39d63 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "laravel/fortify": "^1.30", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", - "laravel/wayfinder": "^0.1.9" + "laravel/wayfinder": "^0.1.9", + "symfony/html-sanitizer": "^8.1" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 9567e2e..0c3aaec 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": "4862ffb94eca7f981210f3a7cf0e75dd", + "content-hash": "1855a7058e5a13f76ca2f99e533c34b2", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4951,6 +4951,78 @@ ], "time": "2026-06-27T09:05:56+00:00" }, + { + "name": "symfony/html-sanitizer", + "version": "v8.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "c67061a71d3f8b55f899faee3167422efa4db027" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/c67061a71d3f8b55f899faee3167422efa4db027", + "reference": "c67061a71d3f8b55f899faee3167422efa4db027", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "php": ">=8.4.1" + }, + "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/v8.1.8" + }, + "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": "2026-09-28T08:00:04+00:00" + }, { "name": "symfony/http-foundation", "version": "v8.1.4", diff --git a/tests/Feature/Tasks/TaskCommentTest.php b/tests/Feature/Tasks/TaskCommentTest.php index 6de74ec..79ccc12 100644 --- a/tests/Feature/Tasks/TaskCommentTest.php +++ b/tests/Feature/Tasks/TaskCommentTest.php @@ -126,4 +126,23 @@ ->assertJsonPath('comments.0.body', 'Top level comment') ->assertJsonPath('comments.0.replies.0.body', 'Nested reply'); }); + + test('sanitizes comment body to remove disallowed tags and attributes', function () { + $task = Task::factory()->create([ + 'team_id' => $this->team->id, + 'column_id' => $this->column->id, + ]); + + $this->actingAs($this->user) + ->post(route('tasks.comments.store', $task), [ + 'body' => '

Helpful comment

link', + ]) + ->assertStatus(204); + + $comment = TaskComment::query()->where('task_id', $task->id)->firstOrFail(); + + expect($comment->body)->toContain('

Helpful comment

') + ->and($comment->body)->not->toContain('', + ]; + + $this->actingAs($this->user) + ->post(route('tasks.store'), $taskData) + ->assertRedirect(route('tasks.index')); + + $task = Task::query()->where('title', 'Task with malicious html')->firstOrFail(); + + expect($task->description)->toContain('

Valid text

') + ->and($task->description)->not->toContain('