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('