From 960c7875453e42d2fcb0925789632461a6923f3d Mon Sep 17 00:00:00 2001 From: Roman Ihoshyn Date: Tue, 4 Feb 2025 16:38:34 +0200 Subject: [PATCH] feat: Add Livewire support. Bump mininmum php version. A bit of refactoring --- .github/workflows/tests.yml | 6 +- Makefile | 7 + README.md | 49 ++++--- composer.json | 14 +- {src => config}/xss-filter.php | 0 src/{ => Cleaner}/Cleaner.php | 23 ++- src/{ => Cleaner}/CleanerConfig.php | 2 +- src/Facade/XSSCleaner.php | 16 +++ src/{ => Middleware}/FilterXSS.php | 31 ++-- src/Middleware/FilterXSSLivewire.php | 46 ++++++ src/XSSCleanerFacade.php | 21 --- src/XSSFilterServiceProvider.php | 22 +-- tests/FilterXSSTest.php | 205 ++++++++++----------------- 13 files changed, 215 insertions(+), 227 deletions(-) create mode 100644 Makefile rename {src => config}/xss-filter.php (100%) rename src/{ => Cleaner}/Cleaner.php (82%) rename src/{ => Cleaner}/CleanerConfig.php (99%) create mode 100644 src/Facade/XSSCleaner.php rename src/{ => Middleware}/FilterXSS.php (58%) create mode 100644 src/Middleware/FilterXSSLivewire.php delete mode 100644 src/XSSCleanerFacade.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7500ff8..7b42b96 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,14 +13,14 @@ jobs: strategy: fail-fast: true matrix: - php: [8.1, 8.2, 8.3] + php: [8.1, 8.2, 8.3, 8.4] stability: [prefer-stable] name: PHP ${{ matrix.php }} - ${{ matrix.stability }} steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -33,4 +33,4 @@ jobs: run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress - name: Execute tests - run: vendor/bin/phpunit + run: vendor/bin/pest diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2dc8b48 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: test + +install: + composer update + +test: + ./vendor/bin/pest diff --git a/README.md b/README.md index 4f1a2f2..da6523d 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,10 @@ ### Configure once and forget about XSS attacks! -Laravel 5.4+ Middleware to filter user inputs from XSS and iframes and other embed elements. - -It does not remove the html, it is only escaped script tags and embeds. - +It does not remove the html, it is only escaped script tags and embeds. However, by default, it does delete inline event listeners such as `onclick`. Optionally they also can be escaped (set `escape_inline_listeners` to `true` in `xss-filter.php` config file). - For example ```php @@ -84,27 +80,44 @@ From command line composer require masterro/laravel-xss-filter ``` -## Step 2: register Service provider and Facade(optional) (for Laravel 5.4) -For your Laravel app, open `config/app.php` and, within the `providers` array, append: - -```php -MasterRO\LaravelXSSFilter\XSSFilterServiceProvider::class -``` -within the `aliases` array, append: -```php -'XSSCleaner' => MasterRO\LaravelXSSFilter\XSSCleanerFacade::class -``` - -## Step 3: publish configs (optional) +## Step 2: publish configs (optional) From command line ``` php artisan vendor:publish --provider="MasterRO\LaravelXSSFilter\XSSFilterServiceProvider" ``` -## Step 4: Middleware +## Step 3: Middleware You can register `\MasterRO\LaravelXSSFilter\FilterXSS::class` for filtering in global middleware stack, group middleware stack or for specific routes. > Have a look at [Laravel's middleware documentation](https://laravel.com/docs/middleware#registering-middleware), if you need any help. +### Livewire +If you are using Livewire you can either register global middleware to all the update livewire requests. This special middleware will clean only required part of Livewire request payload and will not touch snapshot so the component checksum still would be valid. +```php +// AppServiceProvider.php + +public function boot(): void +{ + Livewire::setUpdateRoute(static function ($handle) { + return Route::post('/livewire/update', $handle) + ->middleware(['web', FilterXSSLivewire::class]); + }); +} +``` + +Or you can apply middleware to specific routes and add it to persistent list to ensure inputs are cleared on subsequent component requests: +```php +// AppServiceProvider.php + +public function boot(): void +{ + Livewire::addPersistentMiddleware([ + FilterXSSLivewire::class, + ]); +} +``` + +NOTE! If you have both Livewire components and traditional Controllers you can apply only `FilterXSSLivewire::class` middleware for all required routes or globally. It will fall back to base logic for non Livewire requests. + # Usage After adding middleware, every request will be filtered. diff --git a/composer.json b/composer.json index cfdfbed..9e30042 100644 --- a/composer.json +++ b/composer.json @@ -14,11 +14,12 @@ } ], "require": { - "php": ">=7.4", - "laravel/framework": "^6.20.26|^7.30.6|^8.0|^9.0|^10.0|^11.0" + "php": ">=8.1", + "laravel/framework": "^8.0|^9.0|^10.0|^11.0" }, "require-dev": { - "orchestra/testbench": "^v4.0|^v5.0|^v6.0|^v7.0|^8.0|^9.0" + "orchestra/testbench": "^v6.0|^v7.0|^8.0|^9.0", + "pestphp/pest": "^2.36" }, "autoload": { "psr-4": { @@ -34,8 +35,13 @@ "MasterRO\\LaravelXSSFilter\\XSSFilterServiceProvider" ], "aliases": { - "XSSCleaner": "MasterRO\\LaravelXSSFilter\\XSSCleanerFacade" + "XSSCleaner": "MasterRO\\LaravelXSSFilter\\Facade\\XSSCleaner" } } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } } } diff --git a/src/xss-filter.php b/config/xss-filter.php similarity index 100% rename from src/xss-filter.php rename to config/xss-filter.php diff --git a/src/Cleaner.php b/src/Cleaner/Cleaner.php similarity index 82% rename from src/Cleaner.php rename to src/Cleaner/Cleaner.php index bf8e733..588fc72 100644 --- a/src/Cleaner.php +++ b/src/Cleaner/Cleaner.php @@ -2,19 +2,16 @@ declare(strict_types=1); -namespace MasterRO\LaravelXSSFilter; +namespace MasterRO\LaravelXSSFilter\Cleaner; use Illuminate\Support\Arr; use Illuminate\Support\Str; class Cleaner { - protected CleanerConfig $config; - - public function __construct(CleanerConfig $config) - { - $this->config = $config; - } + public function __construct( + protected CleanerConfig $config, + ) {} public function withConfig(CleanerConfig $config): Cleaner { @@ -51,15 +48,15 @@ public function escapeElements(string $value): string public function cleanMediaElements(string $value): string { - if (! $this->config->allowedMediaHosts()) { + if (!$this->config->allowedMediaHosts()) { return $value; } $allowedUrls = collect($this->config->allowedMediaHosts()) ->map( - fn(string $host) => ! Str::startsWith($host, ['http', 'https', '//']) + fn(string $host) => !Str::startsWith($host, ['http', 'https', '//']) ? ["http://{$host}", "https://{$host}", "//{$host}"] - : [$host] + : [$host], ) ->flatten() ->all(); @@ -72,7 +69,7 @@ public function cleanMediaElements(string $value): string $urls = Arr::get($sources, '1', []); foreach ($urls as $url) { - if (! Str::startsWith($url, $allowedUrls)) { + if (!Str::startsWith($url, $allowedUrls)) { $value = str_replace($url, '#!', $value); } } @@ -87,7 +84,7 @@ public function removeInlineEventListeners(string $value): string $value = preg_replace($pattern, '', $value); } - return ! is_string($value) ? '' : $value; + return !is_string($value) ? '' : $value; } public function escapeInlineEventListeners(string $value): string @@ -96,7 +93,7 @@ public function escapeInlineEventListeners(string $value): string $value = preg_replace_callback($pattern, [$this, 'escapeEqualSign'], $value); } - return ! is_string($value) ? '' : $value; + return !is_string($value) ? '' : $value; } protected function escapeEqualSign(array $matches): string diff --git a/src/CleanerConfig.php b/src/Cleaner/CleanerConfig.php similarity index 99% rename from src/CleanerConfig.php rename to src/Cleaner/CleanerConfig.php index a5b5edf..3d17fc5 100644 --- a/src/CleanerConfig.php +++ b/src/Cleaner/CleanerConfig.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace MasterRO\LaravelXSSFilter; +namespace MasterRO\LaravelXSSFilter\Cleaner; use Illuminate\Support\Str; diff --git a/src/Facade/XSSCleaner.php b/src/Facade/XSSCleaner.php new file mode 100644 index 0000000..c94d948 --- /dev/null +++ b/src/Facade/XSSCleaner.php @@ -0,0 +1,16 @@ +except = config('xss-filter.except', []); - $this->cleaner = $cleaner; } /** @@ -44,17 +30,16 @@ public function __construct(Cleaner $cleaner) * * @return string|mixed */ - protected function transform($key, $value) + protected function transform($key, $value): mixed { if (in_array($key, $this->except, true)) { return $value; } - if (! is_string($value)) { + if (!is_string($value)) { return $value; } return $this->cleaner->clean($value); } - } diff --git a/src/Middleware/FilterXSSLivewire.php b/src/Middleware/FilterXSSLivewire.php new file mode 100644 index 0000000..d218d1d --- /dev/null +++ b/src/Middleware/FilterXSSLivewire.php @@ -0,0 +1,46 @@ +isLivewireRequest($request)) { + return $next($request); + } + + $this->cleanLivewirePayload($request); + + return $next($request); + } + + protected function cleanLivewirePayload(Request $request): void + { + $components = $request->input('components'); + + foreach ($components as $i => &$component) { + if (isset($component['updates'])) { + $component['updates'] = $this->cleanArray($component['updates'], "components.{$i}.updates."); + } + + if (isset($component['calls'])) { + foreach ($component['calls'] as $j => &$call) { + $call['params'] = $this->cleanArray($call['params'], "components.{$i}.calls.{$j}.params."); + } + } + } + + $request->request->set('components', $components); + } + + protected function isLivewireRequest(Request $request): bool + { + return $request->routeIs('*livewire.update'); + } +} diff --git a/src/XSSCleanerFacade.php b/src/XSSCleanerFacade.php deleted file mode 100644 index eb8969d..0000000 --- a/src/XSSCleanerFacade.php +++ /dev/null @@ -1,21 +0,0 @@ -publishes([ - __DIR__ . '/xss-filter.php' => config_path('xss-filter.php'), + __DIR__ . '/../config/xss-filter.php' => config_path('xss-filter.php'), ], 'config'); } - /** - * Register the service provider. - * - * @return void - */ - public function register() + public function register(): void { - $this->mergeConfigFrom(__DIR__ . '/xss-filter.php', 'xss-filter'); + $this->mergeConfigFrom(__DIR__ . '/../config/xss-filter.php', 'xss-filter'); - $this->app->singleton(Cleaner::class, static function () { + $this->app->scoped(Cleaner::class, static function () { return new Cleaner(CleanerConfig::fromArray(config('xss-filter'))); }); } diff --git a/tests/FilterXSSTest.php b/tests/FilterXSSTest.php index c91bda3..e331bdc 100644 --- a/tests/FilterXSSTest.php +++ b/tests/FilterXSSTest.php @@ -5,150 +5,113 @@ namespace MasterRO\LaravelXSSFilter\Tests; use Illuminate\Http\Request; -use Orchestra\Testbench\TestCase; -use MasterRO\LaravelXSSFilter\FilterXSS; +use MasterRO\LaravelXSSFilter\Facade\XSSCleaner; +use MasterRO\LaravelXSSFilter\Middleware\FilterXSS; use MasterRO\LaravelXSSFilter\XSSFilterServiceProvider; -use MasterRO\LaravelXSSFilter\XSSCleanerFacade as XSSCleaner; +use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\Test; -/** - * Class FilterXSSTest - * - * @package MasterRO\LaravelXSSFilter\Tests - */ class FilterXSSTest extends TestCase { - /** - * @var Request - */ - protected $request; + protected ?Request $request = null; /** - * Get Package Providers - * * @param \Illuminate\Foundation\Application $app * * @return string[] */ - protected function getPackageProviders($app) + protected function getPackageProviders($app): array { return [ XSSFilterServiceProvider::class, ]; } - /** - * Request - * - * @param array $data - * @param string $url - * - * @return Request - */ - protected function request($data = [], $url = 'https://example.test/store') + protected function request(array $data = [], string $url = 'https://example.test/store'): Request { $this->request = Request::create($url, 'POST', $data); return $this->request; } - /** - * Response From Middleware With Input - * - * @param array $input - * - * @return \Symfony\Component\HttpFoundation\Response - * @throws \Exception - */ - protected function responseFromMiddlewareWithInput($input = []) + protected function responseFromMiddlewareWithInput(array $input = []): void { - return app(FilterXSS::class) + app(FilterXSS::class) ->handle($this->request($input), function () { // nothing to do here }); } - /** - * @test - */ - public function it_doesnt_change_non_html_inputs() + #[Test] + public function it_doesnt_change_non_html_inputs(): void { $this->responseFromMiddlewareWithInput($input = ['text' => 'Simple text', 'number' => 56]); $this->assertEquals($input, $this->request->all()); } - /** - * @test - */ - public function it_escapes_script_tags() + #[Test] + public function it_escapes_script_tags(): void { $this->responseFromMiddlewareWithInput([ - 'with_src' => 'Before text after text', + 'with_src' => 'Before text after text', 'multiline' => "Before text \n \n After text", ]); $this->assertEquals([ - 'with_src' => 'Before text ' . e('') . ' after text', + 'with_src' => 'Before text ' . e('') . ' after text', 'multiline' => "Before text \n " . e("") . "\n After text", ], $this->request->all()); } - /** - * @test - */ - public function it_doesnt_change_non_script_html_inputs() + #[Test] + public function it_doesnt_change_non_script_html_inputs(): void { $this->responseFromMiddlewareWithInput([ - 'html_with_script_src' => '
link textBefore text after text
test on some text test test test', + 'html_with_script_src' => '
link textBefore text after text
test on some text test test test', 'html_with_script_multiline' => "
\nlink text\n Before text \n \n After text
\n test on some text test test test", ]); $this->assertEquals([ - 'html_with_script_src' => '
link textBefore text ' . e('') . ' after text
test on some text test test test', + 'html_with_script_src' => '
link textBefore text ' . e('') . ' after text
test on some text test test test', 'html_with_script_multiline' => "
\nlink text\n Before text \n " . e("") . "\n After text
\n test on some text test test test", ], $this->request->all()); } - /** - * @test - */ - public function it_escapes_embed_elements() + #[Test] + public function it_escapes_embed_elements(): void { $this->responseFromMiddlewareWithInput([ - 'iframe' => '
Before text after text.
', + 'iframe' => '
Before text after text.
', 'iframe_multiline' => '
\nBefore text\n\n after text.\n
', - 'object' => '
Before text after text.
', + 'object' => '
Before text after text.
', 'object_multiline' => '
\nBefore text\n\n after text.\n
', ]); $this->assertEquals([ - 'iframe' => '
Before text' . e('') . ' after text.
', + 'iframe' => '
Before text' . e('') . ' after text.
', 'iframe_multiline' => '
\nBefore text\n' . e('') . '\n after text.\n
', - 'object' => '
Before text' . e('') . ' after text.
', + 'object' => '
Before text' . e('') . ' after text.
', 'object_multiline' => '
\nBefore text\n' . e('') . '\n after text.\n
', ], $this->request->all()); } - /** - * @test - */ - public function it_removes_inline_listeners() + #[Test] + public function it_removes_inline_listeners(): void { $this->responseFromMiddlewareWithInput([ - 'html' => '

Text ...

', + 'html' => '

Text ...

', 'html_multiline' => "
\n

\nText ...

\n
", ]); $this->assertEquals([ - 'html' => '

Text ...

', + 'html' => '

Text ...

', 'html_multiline' => "
\n

\nText ...

\n
", ], $this->request->all()); } - /** - * @test - */ - public function it_removes_img_inline_listeners() + #[Test] + public function it_removes_img_inline_listeners(): void { $this->responseFromMiddlewareWithInput([ 'html' => 'test', @@ -159,10 +122,8 @@ public function it_removes_img_inline_listeners() ], $this->request->all()); } - /** - * @test - */ - public function it_removes_inline_listeners_with_string_params() + #[Test] + public function it_removes_inline_listeners_with_string_params(): void { $this->responseFromMiddlewareWithInput([ 'html' => 'test', @@ -175,32 +136,28 @@ public function it_removes_inline_listeners_with_string_params() ], $this->request->all()); } - /** - * @test - */ - public function it_removes_inline_listeners_from_invalid_html() + #[Test] + public function it_removes_inline_listeners_from_invalid_html(): void { $this->responseFromMiddlewareWithInput([ - 'html' => '

Text ...

', + 'html' => '

Text ...

', 'html_multiline' => "
\n

\nText ...

\n
", ]); $this->assertEquals([ - 'html' => '

Text ...

', + 'html' => '

Text ...

', 'html_multiline' => "
\n

\nText ...

\n
", ], $this->request->all()); } - /** - * @test - */ - public function it_clears_nested_inputs() + #[Test] + public function it_clears_nested_inputs(): void { $this->responseFromMiddlewareWithInput([ 'value1' => 'Value 1', 'value2' => 2, - 'html' => [ - 'oneline' => '

Text ...

link textBefore text after text
', + 'html' => [ + 'oneline' => '

Text ...

link textBefore text after text
', 'multline' => "
\n

\nText ...

\n
\n
\nlink text\n Before text \n \n After text
", ], 'value3' => [ @@ -212,8 +169,8 @@ public function it_clears_nested_inputs() $this->assertEquals([ 'value1' => 'Value 1', 'value2' => 2, - 'html' => [ - 'oneline' => '

Text ...

link textBefore text ' . e('') . ' after text
', + 'html' => [ + 'oneline' => '

Text ...

link textBefore text ' . e('') . ' after text
', 'multline' => "
\n

\nText ...

\n
\n
\nlink text\n Before text \n " . e("") . "\n After text
", ], 'value3' => [ @@ -223,109 +180,99 @@ public function it_clears_nested_inputs() ], $this->request->all()); } - /** - * @test - */ - public function it_dont_convert_0_to_empty_string() + #[Test] + public function it_dont_convert_0_to_empty_string(): void { $this->responseFromMiddlewareWithInput($input = ['text' => '0']); $this->assertEquals($input, $this->request->all()); } - /** - * @test - */ - public function it_removes_inline_javascript_in_href() + #[Test] + public function it_removes_inline_javascript_in_href(): void { $this->responseFromMiddlewareWithInput([ - 'html' => '
Link
', + 'html' => '
Link
', 'html_multiline' => "
\n

\nLink\n

\n
", ]); $this->assertEquals([ - 'html' => '
Link
', + 'html' => '
Link
', 'html_multiline' => "
\n

\nLink\n

\n
", ], $this->request->all()); } - /** - * @test - */ - public function it_doest_not_touch_other_attributes() + #[Test] + public function it_doest_not_touch_other_attributes(): void { $this->responseFromMiddlewareWithInput([ - 'html' => '

text

', + 'html' => '

text

', 'html_multiline' => "

\n\ntext\n\n

", ]); $this->assertEquals([ - 'html' => '

text

', + 'html' => '

text

', 'html_multiline' => "

\n\ntext\n\n

", ], $this->request->all()); } - /** - * @test - */ - public function it_escapes_inline_event_listeners() + #[Test] + public function it_escapes_inline_event_listeners(): void { XSSCleaner::config()->setEscapeInlineListeners(true); $this->responseFromMiddlewareWithInput([ - 'html' => '

text

', + 'html' => '

text

', 'html_multiline' => "

\n\ntext\n\n

", ]); $this->assertEquals([ - 'html' => '

text

', + 'html' => '

text

', 'html_multiline' => "

\n\ntext\n\n

", ], $this->request->all()); } - /** - * @test - */ - public function it_cleans_disallowed_media_hosts() + #[Test] + public function it_cleans_disallowed_media_hosts(): void { XSSCleaner::config()->allowElement('iframe')->allowMediaHosts(['youtube.com']); $this->responseFromMiddlewareWithInput([ - 'iframe' => '
Before text after text.
', + 'iframe' => '
Before text after text.
', 'iframe_multiline' => '
\nBefore text\n\n after text.\n
', - 'video' => '
Before text after text.
', - 'video_multiline' => '
\nBefore text\n\n after text.\n
', + 'video' => '
Before text after text.
', + 'video_multiline' => '
\nBefore text\n\n after text.\n
', ]); $this->assertEquals([ - 'iframe' => '
Before text after text.
', + 'iframe' => '
Before text after text.
', 'iframe_multiline' => '
\nBefore text\n\n after text.\n
', - 'video' => '
Before text after text.
', - 'video_multiline' => '
\nBefore text\n\n after text.\n
', + 'video' => '
Before text after text.
', + 'video_multiline' => '
\nBefore text\n\n after text.\n
', ], $this->request->all()); } - /** - * @test - */ - public function it_does_not_escape_allowed_media_hosts() + #[Test] + public function it_does_not_escape_allowed_media_hosts(): void { XSSCleaner::config()->allowElement('iframe')->allowMediaHosts([ - 'example.test', 'https://video.test', 'youtu.be', + 'example.test', + 'https://video.test', + 'youtu.be', ]); $this->responseFromMiddlewareWithInput([ - 'iframe' => '
Before text after text.
', + 'iframe' => '
Before text after text.
', 'iframe_multiline' => '
\nBefore text\n\n after text.\n
', - 'video' => '
Before text after text.
', - 'video_multiline' => '
\nBefore text\n\n after text.\n
', + 'video' => '
Before text after text.
', + 'video_multiline' => '
\nBefore text\n\n after text.\n
', ]); $this->assertEquals([ - 'iframe' => '
Before text after text.
', + 'iframe' => '
Before text after text.
', 'iframe_multiline' => '
\nBefore text\n\n after text.\n
', - 'video' => '
Before text after text.
', - 'video_multiline' => '
\nBefore text\n\n after text.\n
', + 'video' => '
Before text after text.
', + 'video_multiline' => '
\nBefore text\n\n after text.\n
', ], $this->request->all()); } }