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
38 changes: 38 additions & 0 deletions app/Casts/CleanHtml.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;

class CleanHtml implements CastsAttributes
{
/**
* Cast the given value.
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
return $value;
}

/**
* Prepare the given value for storage.
*
* @param array<string, mixed> $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);
}
}
2 changes: 2 additions & 0 deletions app/Models/Task.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +45,7 @@ protected function casts(): array
'column_updated_at' => 'datetime',
'due_date' => 'datetime',
'time_spent_in_columns' => 'array',
'description' => CleanHtml::class,
];
}

Expand Down
13 changes: 13 additions & 0 deletions app/Models/TaskComment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,18 @@ class TaskComment extends Model
'body',
];

/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'body' => CleanHtml::class,
];
}

/**
* Get the task that owns the comment.
*/
Expand Down
44 changes: 44 additions & 0 deletions app/Providers/HtmlSanitizerProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

class HtmlSanitizerProvider extends ServiceProvider
{
public function register(): void
{
$this->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
{
//
}
}
2 changes: 2 additions & 0 deletions bootstrap/providers.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
use App\Providers\HtmlSanitizerProvider;

return [
AppServiceProvider::class,
FortifyServiceProvider::class,
HtmlSanitizerProvider::class,
];
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 73 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions tests/Feature/Tasks/TaskCommentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<p>Helpful comment</p><script>alert("xss")</script><a href="javascript:alert(1)">link</a>',
])
->assertStatus(204);

$comment = TaskComment::query()->where('task_id', $task->id)->firstOrFail();

expect($comment->body)->toContain('<p>Helpful comment</p>')
->and($comment->body)->not->toContain('<script>')
->and($comment->body)->not->toContain('javascript:');
});
});
17 changes: 17 additions & 0 deletions tests/Feature/Tasks/TaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,23 @@
'description' => 'This is a detailed narrative.',
]);
});

test('sanitizes task description to remove disallowed tags and attributes', function () {
$taskData = [
'title' => 'Task with malicious html',
'description' => '<p>Valid text</p><script>alert("xss")</script><img src="x" onerror="alert(1)">',
];

$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('<p>Valid text</p>')
->and($task->description)->not->toContain('<script>')
->and($task->description)->not->toContain('onerror');
});
});

describe('update', function () {
Expand Down
Loading