Skip to content

Commit

Permalink
Merge pull request #40 from tonysm/tm/sanitization
Browse files Browse the repository at this point in the history
New Sanitization Example
  • Loading branch information
tonysm authored Feb 19, 2024
2 parents 5ef7912 + cfba1b0 commit 0f76da3
Show file tree
Hide file tree
Showing 9 changed files with 100 additions and 113 deletions.
108 changes: 8 additions & 100 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Similarly to the Content class, the RichText model will implement the `__toStrin
{!! $post->body !!}
```

*Note*: since the HTML output is NOT escaped, make sure you sanitize it before rendering. You can use something like the [mews/purifier](https://github.com/mewebstudio/Purifier) package, see the [sanitization](#sanitization) section for more about this.
*Note*: since the HTML output is NOT escaped, make sure you sanitize it before rendering. See the [sanitization](#sanitization) section for more about this.

The `HasRichText` trait will also add an scope which you can use to eager load the rich text fields (remember, each field will have its own relationship), which you can use like so:

Expand Down Expand Up @@ -217,7 +217,7 @@ And when it renders it again, it will re-render the remote image again inside th
{!! $post->content !!}
```

*Note*: since the HTML output is NOT escaped, make sure you sanitize it before rendering. You can use something like the [mews/purifier](https://github.com/mewebstudio/Purifier) package, see the [sanitization](#sanitization) section for more about this.
*Note*: since the HTML output is NOT escaped, make sure you sanitize it before rendering. See the [sanitization](#sanitization) section for more about this.

When feeding the Trix editor again, you need to do it differently:

Expand Down Expand Up @@ -376,109 +376,17 @@ If you're attaching models, you can implement the `richTextAsPlainText(?string $
### Sanitization
<a name="sanitization"></a>

Since we're rendering user-generated HTML, you must sanitize it to avoid any security issues. Even though we control the input element, malicious users may customize the HTML in the browser and swap it for something else that allows them to enter their own HTML.
Since we're rendering user-generated HTML, you must sanitize it to avoid any security issues. Even though we control the input element, malicious users may tamper with HTML in the browser and swap it for something else that allows them to inject their own HTML.

We suggest using something like [mews/purifier](https://github.com/mewebstudio/Purifier) package before any final render (with the exception of rendering inside the value attribute of the input field that feeds Trix). That would look like this:
We recommend using [Symfony's HTML Sanitizer](https://symfony.com/doc/current/html_sanitizer.html). The Workbench application in this repository ships with a sample implementation. Here's some relevant info:

```php
{!! clean($post->body) !!}
```

You need to add some customizations to the config file created when you install the `mews/purifier` package, like so:

```php
return [
// ...
'settings' => [
// ...
'default' => [
// ...
'HTML.Allowed' => 'rich-text-attachment[sgid|content-type|url|href|filename|filesize|height|width|previewable|presentation|caption|data-trix-attachment|data-trix-attributes],div,b,strong,i,em,u,a[href|title],ul,ol,li,p[style],br,span[style],img[width|height|alt|src],del,h1,blockquote,figure[data-trix-attributes|data-trix-attachment],figcaption,*[class]',
],
// ...
'custom_definition' => [
// ...
'elements' => [
// ...
['rich-text-attachment', 'Block', 'Flow', 'Common'],
],
],
// ...
'custom_attributes' => [
// ...
['rich-text-attachment', 'sgid', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'content-type', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'url', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'href', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'filename', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'filesize', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'height', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'width', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'previewable', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'presentation', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'caption', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'data-trix-attachment', new HTMLPurifier_AttrDef_Text],
['rich-text-attachment', 'data-trix-attributes', new HTMLPurifier_AttrDef_Text],
['figure', 'data-trix-attachment', new HTMLPurifier_AttrDef_Text],
['figure', 'data-trix-attributes', new HTMLPurifier_AttrDef_Text],
],
// ...
'custom_elements' => [
// ...
['rich-text-attachment', 'Block', 'Flow', 'Common'],
],
],
];
```
- You **MUST ALWAYS** escape both the HTML and plain text version of the HTML generated by the package. Never trust user-generated content.
- One example of escaped content is in the [resources/views/posts/show.blade.php](workbench/resources/views/posts/show.blade.php). Notice that the Rich Text Attributes are being passed to the `clean()` function;
- The [`clean()` function](workbench/helpers.php) creates the Sanitizer (see the [factory](workbench/app/Html/SanitizerFactory.php)), which is a thin abstraction on top of Symfony's HTML Sanitizer (see the [Sanitizer](workbench/app/Html/Sanitizer.php));
- In all examples of the Workbench app we're only sanitizing the content on render. You may also consider sanitizing it after validation, even before passing it down to the model.

**Attention**: I'm not an expert in HTML content sanitization, so take this with an extra grain of salt and, please, consult someone more with more security experience on this if you can.

### Email Rendering

If you'd like to send your Trix content by email, it can be rendered in a [Mailable](https://laravel.com/docs/9.x/mail) and delivered to users. Laravel's default email theme presents Trix content cleanly, even if you're using Markdown messages.

To ensure your content displays well across different email clients, you should always sanitize your rendered HTML with the `mews/purifier` package, as detailed above, but using a custom ruleset to remove tags which could affect the message layout.

Add a new `mail` rule to the `mews/purifier` configuration (being mindful of earlier comments about sanitization and security):

```php
return [
// ...
'settings' => [
// ...
'mail' => [
'HTML.Allowed' => 'div,b,strong,i,em,u,a[href|title],ul,ol,li,p[style],br,span[style],img[alt|src],del,h1,h2,sup,blockquote,figure,figcaption,*[class]',
'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align',
'AutoFormat.AutoParagraph' => true,
'AutoFormat.RemoveEmpty' => true,
],
// ...
```

This rule differs from the normal configuration by removing `width` and `height` tags from `<img>` elements, and turning `<pre>` and `<code>` tags into normal paragraphs (as these seem to trip up the Markdown parser). If you rely on code blocks in Trix, you may be able to adjust the sanitizer ruleset to work around this.

To send the rich text content by email, create a Blade template for the message like in the example below:

```php
@component('mail::message')

# Hi {{ $user->name }},

## We've just published a new article: {{ $article->title }}

<!-- //@formatter:off -->
{!! preg_replace('/^ +/m', '', clean($article->content->render(), 'mail')) !!}
<!-- //@formatter:on -->

@endcomponent
```

Whilst the Blade message uses Markdown for the greeting, the Trix content will be rendered as HTML. This will only render correctly if it's *not indented in any way* — otherwise the Markdown parser tries to interpret nested content as code blocks.

The `preg_replace()` function is used to remove leading whitespace from each rendered line of Trix content, after it's been cleaned. The second parameter in the `clean()` function tells it to reference the `mail` config entry, described above.

The `//@formatter:*` comments are optional, but if you use an IDE like PhpStorm, these comments prevent it from trying to auto-indent the element if you run code cleanup tools.

### SGID

When storing references of custom attachments, the package uses another package called [GlobalID Laravel](https://github.com/tonysm/globalid-laravel). We store a Signed Global ID, which means users cannot simply change the sgids at-rest. They would need to generate another valid signature using the `APP_KEY`, which is secret.
Expand Down
6 changes: 4 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"nunomaduro/collision": "^6.0",
"orchestra/testbench": "^8.21",
"orchestra/workbench": "^1.0",
"phpunit/phpunit": "^9.6"
"phpunit/phpunit": "^9.6",
"symfony/html-sanitizer": "^7.0"
},
"autoload": {
"psr-4": {
Expand All @@ -39,7 +40,8 @@
"Workbench\\App\\": "workbench/app/",
"Workbench\\Database\\Factories\\": "workbench/database/factories/",
"Workbench\\Database\\Seeders\\": "workbench/database/seeders/"
}
},
"files": ["workbench/helpers.php"]
},
"scripts": {
"psalm": "vendor/bin/psalm",
Expand Down
19 changes: 19 additions & 0 deletions workbench/app/Html/Sanitizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Workbench\App\Html;

use Illuminate\Support\HtmlString;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;

class Sanitizer
{
public function __construct(private HtmlSanitizer $sanitizer)
{
//
}

public function sanitize(string $html, string $element = 'body'): HtmlString
{
return new HtmlString($this->sanitizer->sanitizeFor($element, $html));
}
}
47 changes: 47 additions & 0 deletions workbench/app/Html/SanitizerFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Workbench\App\Html;

use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

class SanitizerFactory
{
private static $cache = [];

public static function make($config = null): Sanitizer
{
return new Sanitizer(new HtmlSanitizer(static::configFor($config)));
}

private static function configFor($config = null): HtmlSanitizerConfig
{
return static::$cache[$config ?? 'default'] ??= match ($config) {
'minimal' => static::minimalConfig(),
default => static::defaultConfig(),
};
}

private static function defaultConfig(): HtmlSanitizerConfig
{
return (new HtmlSanitizerConfig())
->allowSafeElements()
->allowAttribute('class', '*');
}

private static function minimalConfig(): HtmlSanitizerConfig
{
return (new HtmlSanitizerConfig())
->allowElement('br')
->allowElement('div')
->allowElement('p')
->allowElement('span')
->allowElement('img', ['class', 'src', 'alt'])
->allowElement('a', ['href'])
->allowElement('strong')
->allowElement('b')
->allowElement('em')
->allowElement('i')
->allowAttribute('class', '*');
}
}
10 changes: 10 additions & 0 deletions workbench/helpers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

use Workbench\App\Html\SanitizerFactory;

if (! function_exists('clean')) {
function clean(string $html, string $element = 'body', $config = null)
{
return SanitizerFactory::make($config)->sanitize($html, $element);
}
}
8 changes: 4 additions & 4 deletions workbench/resources/views/chat/partials/composer.blade.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<form action="{{ route('messages.store') }}" method="post" class="group p-4 sm:p-0" data-controller="composer">
@csrf

<div class="flex text-xl items-end space-x-1 px-1 justify-between rounded-lg sm:rounded-3xl border border-gray-400 bg-white focus-within:border-transparent focus-within:ring-4">
<div class="flex items-end space-x-1 px-1 justify-between rounded-lg sm:rounded-3xl border border-gray-400 bg-white focus-within:border-transparent focus-within:ring-4">
<div class="shrink-0 flex items-end my-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-9 h-9">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" />
</svg>
</div>
Expand All @@ -14,14 +14,14 @@

<div class="shrink-0 flex flex-row-reverse items-end my-1">
<button type="submit" class="ml-2 rounded-full bg-black text-white p-2 focus:ring focus:ring-inset focus:ring-white">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18" />
</svg>
<span class="sr-only">{{ __('Send') }}</span>
</button>

<button type="button" data-action="composer#toggleToolbar" class="rounded-full text-black p-2 focus:ring-2 focus:ring-inset focus:ring-yello-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" stroke="currentColor" class="w-6 h-6">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" stroke="currentColor" class="w-5 h-5">
<path d="M3 19V1h8a5 5 0 0 1 3.88 8.16A5.5 5.5 0 0 1 11.5 19H3zm7.5-8H7v5h3.5a2.5 2.5 0 1 0 0-5zM7 4v4h3a2 2 0 1 0 0-4H7z"/>
</svg>

Expand Down
2 changes: 1 addition & 1 deletion workbench/resources/views/chat/partials/message.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
])
>
<div class="prose prose-pre:text-gray-900 break-words prose-pre:text-sm prose-blockquote:my-0">
{!! $message->content !!}
{{ clean($message->content) }}
</div>

<div class="flex justify-end">
Expand Down
2 changes: 1 addition & 1 deletion workbench/resources/views/components/app-layout.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
}
</script>

<x-rich-text::styles />
<x-rich-text::styles theme="richtextlaravel" />

{{-- Tribute's Styles... --}}
<link rel="stylesheet" href="https://unpkg.com/tributejs@5.1.3/dist/tribute.css">
Expand Down
11 changes: 6 additions & 5 deletions workbench/resources/views/posts/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
<h1 class="text-xl">The HTML version:</h1>

<x-info>
<span>Here is how the document will render. Notice the content is not escaped. That's dangerous! <strong>YOU MUST</strong> escape the Trix HTML document using something like <a class="text-blue-600 underline underline-offset-4" href="https://symfony.com/doc/current/html_sanitizer.html">Symfony's HTML Sanitizer</a>:</span>
<span>Here is how the document will render. Notice the HTML content is being cleaned up using <a class="text-blue-600 underline underline-offset-4" href="https://symfony.com/doc/current/html_sanitizer.html">Symfony's HTML Sanitizer</a>. You <strong>MUST</strong> always escape both the HTML and the plain text version of the user-generated content.</span>
</x-info>

<div class="rounded border shadow p-6 trix-content bg-white">
{{-- DON'T DO THIS. YOU MUST SANITIZE IN PRODUTION. --}}
{!! $post->body !!}
{{-- YOU MUST ALWAYS ESCAPE THE USER-ENTERED HTML. --}}
{{ clean($post->body) }}
</div>

<h1 class="text-xl">The Plain Text version:</h1>
Expand All @@ -31,6 +31,7 @@
<span>You may also render the document in plain text. Please, notice that even the plain text version <strong>MUST</strong> be escaped too, as image's captions and other custom attachments may contain user input:</span>
</x-info>

{{-- YOU MUST ALWAYS ESCAPE THE PLAIN-TEXT VERSION TOO. --}}
<div class="rounded border shadow p-6 space-y-2 whitespace-pre-line bg-white">{{ $post->body->toPlainText() }}</div>

<h1 class="text-xl">Links in the document:</h1>
Expand Down Expand Up @@ -66,8 +67,8 @@
@foreach($post->comments as $comment)
<div id="comment_{{ $comment->id }}" class="bg-white p-4 rounded">
<strong>You said:</strong>
{{-- YOU MUST ESCAPE THE HTML IN A REAL APP --}}
<div>{!! $comment->content !!}</div>
{{-- YOU MUST ALWAYS ESCAPE THE USER-ENTERED HTML. --}}
<div>{{ clean($comment->content, config: 'minimal') }}</div>
</div>
@endforeach

Expand Down

0 comments on commit 0f76da3

Please sign in to comment.