From de23f8cc1e0b56b289b81d99a3e98a9d68d5e2a5 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 22 Apr 2026 14:33:01 -0700 Subject: [PATCH 01/12] fix(deps): add stevebauman/purify for HTML sanitization Adds HTMLPurifier via the stevebauman/purify Laravel wrapper to sanitize user-supplied HTML content before storage, preventing stored XSS attacks through rich-text fields. --- composer.json | 3 +- composer.lock | 131 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 88c2ac6508..164523713f 100644 --- a/composer.json +++ b/composer.json @@ -44,9 +44,10 @@ "sentry/sentry-laravel": "^4.13", "soundasleep/html2text": "^1.1", "spatie/calendar-links": "^1.6", - "spatie/laravel-validation-rules": "^3.4", "spatie/laravel-html": "^3.12.0", + "spatie/laravel-validation-rules": "^3.4", "spinen/laravel-discourse-sso": "dev-l12-compatibility", + "stevebauman/purify": "^6.3", "symfony/http-client": "^7.2", "symfony/http-foundation": "^7.2", "symfony/mailgun-mailer": "^7.2", diff --git a/composer.lock b/composer.lock index 0be2dcd596..dffeae7e4c 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": "a79d8dc54c6b1514c5b254e265806d8f", + "content-hash": "3c3716ad51a3bef71683f8aa7d634fe5", "packages": [ { "name": "addwiki/mediawiki-api", @@ -1706,6 +1706,67 @@ ], "time": "2023-06-01T07:04:22+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "filp/whoops", "version": "2.18.0", @@ -7177,6 +7238,72 @@ }, "time": "2025-02-17T00:51:48+00:00" }, + { + "name": "stevebauman/purify", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/stevebauman/purify.git", + "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/deba4aa55a45a7593c369b52d481c87b545a5bf8", + "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.17", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": ">=7.4" + }, + "require-dev": { + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3|^12.5.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Purify": "Stevebauman\\Purify\\Facades\\Purify" + }, + "providers": [ + "Stevebauman\\Purify\\PurifyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Stevebauman\\Purify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com" + } + ], + "description": "An HTML Purifier / Sanitizer for Laravel", + "keywords": [ + "Purifier", + "clean", + "cleaner", + "html", + "laravel", + "purification", + "purify" + ], + "support": { + "issues": "https://github.com/stevebauman/purify/issues", + "source": "https://github.com/stevebauman/purify/tree/v6.3.2" + }, + "time": "2026-03-18T16:42:42+00:00" + }, { "name": "swagger-api/swagger-ui", "version": "v5.21.0", @@ -13980,5 +14107,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From e9181e7561ca48979707d80de1b78e25a113416e Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 22 Apr 2026 14:33:18 -0700 Subject: [PATCH 02/12] fix(xss): sanitize rich-text descriptions on storage Group and event descriptions (free_text) were stored as raw user HTML and rendered unescaped, allowing stored XSS. Now sanitized with HTMLPurifier before storage, stripping dangerous tags like script and event handlers while preserving safe HTML. --- app/Http/Controllers/API/EventController.php | 5 +++-- app/Http/Controllers/API/GroupController.php | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/API/EventController.php b/app/Http/Controllers/API/EventController.php index e3eae326b2..44abede45b 100644 --- a/app/Http/Controllers/API/EventController.php +++ b/app/Http/Controllers/API/EventController.php @@ -9,6 +9,7 @@ use App\Helpers\Fixometer; use App\Helpers\FixometerFile; use App\Http\Controllers\Controller; +use Stevebauman\Purify\Facades\Purify; use App\Models\Invite; use App\Models\Network; use App\Notifications\AdminModerationEvent; @@ -489,7 +490,7 @@ public function createEventv2(Request $request): JsonResponse 'timezone' => $timezone, 'event_start_utc' => $event_start_utc, 'event_end_utc' => $event_end_utc, - 'free_text' => $description, + 'free_text' => Purify::clean($description), 'link' => $link, 'venue' => $title, 'location' => $location, @@ -654,7 +655,7 @@ public function updateEventv2(Request $request, $idEvents): JsonResponse 'event_start_utc' => $event_start_utc, 'event_end_utc' => $event_end_utc, 'hours' => $hours, - 'free_text' => $description, + 'free_text' => Purify::clean($description), 'online' => $online, 'venue' => $title, 'link' => $link, diff --git a/app/Http/Controllers/API/GroupController.php b/app/Http/Controllers/API/GroupController.php index f42d94ba4b..de30935b2b 100644 --- a/app/Http/Controllers/API/GroupController.php +++ b/app/Http/Controllers/API/GroupController.php @@ -10,6 +10,7 @@ use App\Helpers\Fixometer; use App\Helpers\FixometerFile; use App\Http\Controllers\Controller; +use Stevebauman\Purify\Facades\Purify; use App\Http\Resources\PartySummaryCollection; use App\Http\Resources\TagCollection; use App\Http\Resources\VolunteerCollection; @@ -830,7 +831,7 @@ public function createGroupv2(Request $request): JsonResponse { 'latitude' => $latitude, 'longitude' => $longitude, 'country_code' => $country, - 'free_text' => $description, + 'free_text' => Purify::clean($description), 'shareable_code' => Fixometer::generateUniqueShareableCode(\App\Models\Group::class, 'shareable_code'), 'timezone' => $timezone, 'phone' => $phone, @@ -1005,7 +1006,7 @@ public function updateGroupv2(Request $request, $idGroup): JsonResponse { 'latitude' => $latitude, 'longitude' => $longitude, 'country_code' => $country, - 'free_text' => $description, + 'free_text' => Purify::clean($description), 'timezone' => $timezone, 'phone' => $phone, 'network_data' => $network_data, From c6d0a439b82123f3ab7e6c69493f9df8fc36cd6a Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 22 Apr 2026 14:33:50 -0700 Subject: [PATCH 03/12] fix(xss): escape user data in flash messages and v-html User-controlled values (group names, event venues, user names, network names) were interpolated into HTML translation strings and rendered unescaped via {!! !!} or v-html. Now all user data is escaped with e() (PHP) or DOM textContent (JS) before interpolation into HTML contexts. --- app/Http/Controllers/GroupController.php | 6 +++--- app/Http/Middleware/AcceptUserInvites.php | 8 ++++---- resources/js/components/GroupPage.vue | 5 ++++- resources/views/networks/show.blade.php | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php index fe69022718..ca28014d04 100644 --- a/app/Http/Controllers/GroupController.php +++ b/app/Http/Controllers/GroupController.php @@ -455,7 +455,7 @@ public function delete($id): RedirectResponse $group->delete(); return redirect('/group')->with('success', __('groups.delete_succeeded', [ - 'name' => $name, + 'name' => e($name), ])); } else { return redirect('/user/forbidden'); @@ -576,7 +576,7 @@ public function getJoinGroup($group_id): RedirectResponse return redirect() ->back() ->with('success', __('groups.now_following', [ - 'name' => $group->name, + 'name' => e($group->name), 'link' => url('/group/view/'.$group->idgroups), ])); } catch (\Exception $e) { @@ -661,7 +661,7 @@ public function inviteNearbyRestarter($groupId, $userId): RedirectResponse } } - return redirect('/group/nearby/'.intval($groupId))->with('success', $user->name.' has been invited'); + return redirect('/group/nearby/'.intval($groupId))->with('success', e($user->name).' has been invited'); } /** diff --git a/app/Http/Middleware/AcceptUserInvites.php b/app/Http/Middleware/AcceptUserInvites.php index 465d1c2af1..3631e4d762 100644 --- a/app/Http/Middleware/AcceptUserInvites.php +++ b/app/Http/Middleware/AcceptUserInvites.php @@ -45,13 +45,13 @@ public function handle(Request $request, Closure $next): Response $acceptance->delete(); $request->session()->push('invites-feedback', __('groups.you_have_joined', [ 'url' => url("/group/view/{$group->idgroups}"), - 'name' => $group->name + 'name' => e($group->name) ])); // Else that must mean the User is already part of the Group. // We can then delete the Invite and create a new session } else { - $request->session()->push('invites-feedback', 'You are already a member of idgroups}").'">'.e($group->name).''); } } $request->session()->forget('groups'); @@ -78,13 +78,13 @@ public function handle(Request $request, Closure $next): Response $acceptance->delete(); $request->session()->push('invites-feedback', __('events.you_have_joined', [ 'url' => url("/party/view/{$event->idevents}"), - 'name' => $event->venue + 'name' => e($event->venue) ])); // Else that must mean the User is already part of the Event. // We can then delete the Invite and create a new session } else { - $request->session()->push('invites-feedback', 'You are already a member of idevents}").'">'.e($event->venue).''); } } $request->session()->forget('events'); diff --git a/resources/js/components/GroupPage.vue b/resources/js/components/GroupPage.vue index 1fcc137cf3..8827fc2740 100644 --- a/resources/js/components/GroupPage.vue +++ b/resources/js/components/GroupPage.vue @@ -164,8 +164,11 @@ export default { return this.$store.getters['groups/get'](this.idgroups) }, translatedHaveLeft() { + const el = document.createElement('span') + el.textContent = this.group.name + const escapedName = el.innerHTML return this.$lang.get('groups.now_unfollowed', { - name: this.group.name, + name: escapedName, link: '/group/view/' + this.group.id }) } diff --git a/resources/views/networks/show.blade.php b/resources/views/networks/show.blade.php index 68a4c551dc..9a19b3f56d 100644 --- a/resources/views/networks/show.blade.php +++ b/resources/views/networks/show.blade.php @@ -110,7 +110,7 @@

{!! __('networks.general.count', [ 'count' => $network->groups->count(), - 'name' => $network->name, + 'name' => e($network->name), 'id' => $network->id ]) !!}

From 6c3b521daf9793af29a211f04ffa9b16f3db6826 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 22 Apr 2026 14:46:33 -0700 Subject: [PATCH 04/12] chore(config): publish purify configuration Publishes the default HTMLPurifier config from stevebauman/purify, which controls which HTML tags and attributes are allowed through sanitization. --- config/purify.php | 115 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 config/purify.php diff --git a/config/purify.php b/config/purify.php new file mode 100644 index 0000000000..66dbbb5683 --- /dev/null +++ b/config/purify.php @@ -0,0 +1,115 @@ + 'default', + + /* + |-------------------------------------------------------------------------- + | Config sets + |-------------------------------------------------------------------------- + | + | Here you may configure various sets of configuration for differentiated use of HTMLPurifier. + | A specific set of configuration can be applied by calling the "config($name)" method on + | a Purify instance. Feel free to add/remove/customize these attributes as you wish. + | + | Documentation: http://htmlpurifier.org/live/configdoc/plain.html + | + | Core.Encoding The encoding to convert input to. + | HTML.Doctype Doctype to use during filtering. + | HTML.Allowed The allowed HTML Elements with their allowed attributes. + | HTML.ForbiddenElements The forbidden HTML elements. Elements that are listed in this + | string will be removed, however their content will remain. + | CSS.AllowedProperties The Allowed CSS properties. + | AutoFormat.AutoParagraph Newlines are converted in to paragraphs whenever possible. + | AutoFormat.RemoveEmpty Remove empty elements that contribute no semantic information to the document. + | + */ + + 'configs' => [ + + 'default' => [ + 'Core.Encoding' => 'utf-8', + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'h1,h2,h3,h4,h5,h6,b,u,strong,i,em,s,del,a[href|title],ul,ol,li,p[style],br,span,img[width|height|alt|src],blockquote', + 'HTML.ForbiddenElements' => '', + 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align', + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | HTMLPurifier definitions + |-------------------------------------------------------------------------- + | + | Here you may specify a class that augments the HTML definitions used by + | HTMLPurifier. Additional HTML5 definitions are provided out of the box. + | When specifying a custom class, make sure it implements the interface: + | + | \Stevebauman\Purify\Definitions\Definition + | + | Note that these definitions are applied to every Purifier instance. + | + | Documentation: http://htmlpurifier.org/docs/enduser-customize.html + | + */ + + 'definitions' => Html5Definition::class, + + /* + |-------------------------------------------------------------------------- + | HTMLPurifier CSS definitions + |-------------------------------------------------------------------------- + | + | Here you may specify a class that augments the CSS definitions used by + | HTMLPurifier. When specifying a custom class, make sure it implements + | the interface: + | + | \Stevebauman\Purify\Definitions\CssDefinition + | + | Note that these definitions are applied to every Purifier instance. + | + | CSS should be extending $definition->info['css-attribute'] = values + | See HTMLPurifier_CSSDefinition for further explanation + | + */ + + 'css-definitions' => null, + + /* + |-------------------------------------------------------------------------- + | Serializer + |-------------------------------------------------------------------------- + | + | The storage implementation where HTMLPurifier can store its serializer files. + | If the filesystem cache is in use, the path must be writable through the + | storage disk by the web server, otherwise an exception will be thrown. + | + */ + + 'serializer' => [ + 'driver' => env('CACHE_STORE', env('CACHE_DRIVER', 'file')), + 'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class, + ], + + // 'serializer' => [ + // 'disk' => env('FILESYSTEM_DISK', 'local'), + // 'path' => 'purify', + // 'cache' => \Stevebauman\Purify\Cache\FilesystemDefinitionCache::class, + // ], + +]; From d71c26243b76ac403f94836dbafe67986b8bacae Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Thu, 23 Apr 2026 09:06:22 -0700 Subject: [PATCH 05/12] fix(xss): add model mutators for input sanitization Adds sanitization mutators at the model layer so all write paths (controllers, CSV imports, tinker, seeders) are covered. Plain-text fields like names and venues use strip_tags() to remove HTML entirely. Rich-text fields like descriptions and biographies use Purify::clean() to strip dangerous tags while preserving safe formatting HTML. --- app/Models/Brands.php | 5 +++++ app/Models/Category.php | 5 +++++ app/Models/Group.php | 11 +++++++++++ app/Models/GroupTags.php | 5 +++++ app/Models/Network.php | 11 +++++++++++ app/Models/Party.php | 11 +++++++++++ app/Models/Skills.php | 5 +++++ app/Models/User.php | 11 +++++++++++ 8 files changed, 64 insertions(+) diff --git a/app/Models/Brands.php b/app/Models/Brands.php index bb0dad6dd1..1535a94715 100644 --- a/app/Models/Brands.php +++ b/app/Models/Brands.php @@ -21,4 +21,9 @@ class Brands extends Model * @var array */ protected $hidden = []; + + public function setBrandNameAttribute($value) + { + $this->attributes['brand_name'] = $value === null ? null : strip_tags((string) $value); + } } diff --git a/app/Models/Category.php b/app/Models/Category.php index 0b8f0ddf20..e678160513 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -33,6 +33,11 @@ class Category extends Model // Setters + public function setNameAttribute($value) + { + $this->attributes['name'] = $value === null ? null : strip_tags((string) $value); + } + //Getters public function findAll() { diff --git a/app/Models/Group.php b/app/Models/Group.php index b949d3d555..a49a083c1a 100644 --- a/app/Models/Group.php +++ b/app/Models/Group.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Lang; use Illuminate\Support\Facades\Log; +use Stevebauman\Purify\Facades\Purify; use OwenIt\Auditing\Contracts\Auditable; use Illuminate\Database\Eloquent\SoftDeletes; @@ -446,6 +447,16 @@ public function setDistanceAttribute($val) $this->distance = $val; } + public function setNameAttribute($value) + { + $this->attributes['name'] = $value === null ? null : strip_tags((string) $value); + } + + public function setFreeTextAttribute($value) + { + $this->attributes['free_text'] = $value === null ? null : Purify::clean((string) $value); + } + public function createDiscourseGroup() { // Get the host who created the group. $success = false; diff --git a/app/Models/GroupTags.php b/app/Models/GroupTags.php index ae1ad1bffc..9ffbeba2a0 100644 --- a/app/Models/GroupTags.php +++ b/app/Models/GroupTags.php @@ -42,5 +42,10 @@ public function groupTagGroups(): HasMany // Setters + public function setTagNameAttribute($value) + { + $this->attributes['tag_name'] = $value === null ? null : strip_tags((string) $value); + } + //Getters } diff --git a/app/Models/Network.php b/app/Models/Network.php index 2a0bda8ba6..fa18de9afb 100644 --- a/app/Models/Network.php +++ b/app/Models/Network.php @@ -6,11 +6,22 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use App\Models\Group; use Illuminate\Database\Eloquent\Model; +use Stevebauman\Purify\Facades\Purify; class Network extends Model { use HasFactory; + public function setNameAttribute($value) + { + $this->attributes['name'] = $value === null ? null : strip_tags((string) $value); + } + + public function setDescriptionAttribute($value) + { + $this->attributes['description'] = $value === null ? null : Purify::clean((string) $value); + } + public function groups(): BelongsToMany { return $this->belongsToMany(Group::class, 'group_network', 'network_id', 'group_id'); diff --git a/app/Models/Party.php b/app/Models/Party.php index 25aadd1a2e..e72a36a3b4 100644 --- a/app/Models/Party.php +++ b/app/Models/Party.php @@ -13,6 +13,7 @@ use DB; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Stevebauman\Purify\Facades\Purify; use Illuminate\Support\Str; use Notification; use OwenIt\Auditing\Contracts\Auditable; @@ -767,6 +768,16 @@ public function getEventEndUtcAttribute() { return array_key_exists('event_end_utc', $this->attributes) ? Carbon::parse($this->attributes['event_end_utc'], 'UTC')->toIso8601String() : null; } + public function setVenueAttribute($value) + { + $this->attributes['venue'] = $value === null ? null : strip_tags((string) $value); + } + + public function setFreeTextAttribute($value) + { + $this->attributes['free_text'] = $value === null ? null : Purify::clean((string) $value); + } + // Mutators for previous event_date/start/end fields. These are now superceded by the UTC fields and therefore // should never be set directly. Throw exceptions to ensure that they are not. public function setEventDateAttribute($val) { diff --git a/app/Models/Skills.php b/app/Models/Skills.php index fb5a5a98b8..efa9d547a0 100644 --- a/app/Models/Skills.php +++ b/app/Models/Skills.php @@ -26,5 +26,10 @@ class Skills extends Model // Setters + public function setSkillNameAttribute($value) + { + $this->attributes['skill_name'] = $value === null ? null : strip_tags((string) $value); + } + //Getters } diff --git a/app/Models/User.php b/app/Models/User.php index 9cd00516e5..af2afb1cd0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use App\Events\UserDeleted; use App\Events\UserUpdated; +use Stevebauman\Purify\Facades\Purify; use App\Helpers\Fixometer; use App\Models\Network; use App\Models\UserGroups; @@ -64,6 +65,16 @@ class User extends Authenticatable implements Auditable, HasLocalePreference 'remember_token', ]; + public function setNameAttribute($value) + { + $this->attributes['name'] = $value === null ? null : strip_tags((string) $value); + } + + public function setBiographyAttribute($value) + { + $this->attributes['biography'] = $value === null ? null : Purify::clean((string) $value); + } + /** * The event map for the model. * From 03d25839fb9090b1c60c70cad1bd821164f6231d Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Thu, 23 Apr 2026 09:06:39 -0700 Subject: [PATCH 06/12] refactor(xss): remove redundant controller-level Purify calls Model mutators now handle sanitization of free_text fields, so the explicit Purify::clean() calls in the API controllers are redundant and can be removed. --- app/Http/Controllers/API/EventController.php | 5 ++--- app/Http/Controllers/API/GroupController.php | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/API/EventController.php b/app/Http/Controllers/API/EventController.php index 44abede45b..e3eae326b2 100644 --- a/app/Http/Controllers/API/EventController.php +++ b/app/Http/Controllers/API/EventController.php @@ -9,7 +9,6 @@ use App\Helpers\Fixometer; use App\Helpers\FixometerFile; use App\Http\Controllers\Controller; -use Stevebauman\Purify\Facades\Purify; use App\Models\Invite; use App\Models\Network; use App\Notifications\AdminModerationEvent; @@ -490,7 +489,7 @@ public function createEventv2(Request $request): JsonResponse 'timezone' => $timezone, 'event_start_utc' => $event_start_utc, 'event_end_utc' => $event_end_utc, - 'free_text' => Purify::clean($description), + 'free_text' => $description, 'link' => $link, 'venue' => $title, 'location' => $location, @@ -655,7 +654,7 @@ public function updateEventv2(Request $request, $idEvents): JsonResponse 'event_start_utc' => $event_start_utc, 'event_end_utc' => $event_end_utc, 'hours' => $hours, - 'free_text' => Purify::clean($description), + 'free_text' => $description, 'online' => $online, 'venue' => $title, 'link' => $link, diff --git a/app/Http/Controllers/API/GroupController.php b/app/Http/Controllers/API/GroupController.php index de30935b2b..f42d94ba4b 100644 --- a/app/Http/Controllers/API/GroupController.php +++ b/app/Http/Controllers/API/GroupController.php @@ -10,7 +10,6 @@ use App\Helpers\Fixometer; use App\Helpers\FixometerFile; use App\Http\Controllers\Controller; -use Stevebauman\Purify\Facades\Purify; use App\Http\Resources\PartySummaryCollection; use App\Http\Resources\TagCollection; use App\Http\Resources\VolunteerCollection; @@ -831,7 +830,7 @@ public function createGroupv2(Request $request): JsonResponse { 'latitude' => $latitude, 'longitude' => $longitude, 'country_code' => $country, - 'free_text' => Purify::clean($description), + 'free_text' => $description, 'shareable_code' => Fixometer::generateUniqueShareableCode(\App\Models\Group::class, 'shareable_code'), 'timezone' => $timezone, 'phone' => $phone, @@ -1006,7 +1005,7 @@ public function updateGroupv2(Request $request, $idGroup): JsonResponse { 'latitude' => $latitude, 'longitude' => $longitude, 'country_code' => $country, - 'free_text' => Purify::clean($description), + 'free_text' => $description, 'timezone' => $timezone, 'phone' => $phone, 'network_data' => $network_data, From ef9ae01343a9bac3317ab67d2057fb55fc7192e9 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Thu, 23 Apr 2026 09:07:01 -0700 Subject: [PATCH 07/12] fix(xss): escape user data in ConfirmModal and soft-delete flash Escapes group.name before passing to ConfirmModal's v-html rendered delete/archive confirmation messages in GroupActions.vue. Also escapes user name in the soft-delete flash message in UserController to prevent stored XSS when an admin deletes a user with a malicious name. --- app/Http/Controllers/UserController.php | 2 +- resources/js/components/GroupActions.vue | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index fe174a0c8f..419ba1f523 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -294,7 +294,7 @@ public function postSoftDeleteUser(Request $request): RedirectResponse if (Auth::id() !== $user_id) { return redirect('user/all')->with('danger', __('profile.soft_deleted', [ - 'name' => $old_user_name + 'name' => e($old_user_name) ])); } else { return redirect('login'); diff --git a/resources/js/components/GroupActions.vue b/resources/js/components/GroupActions.vue index eabb0bdc6d..73046e2656 100644 --- a/resources/js/components/GroupActions.vue +++ b/resources/js/components/GroupActions.vue @@ -56,10 +56,10 @@ @@ -100,6 +100,11 @@ export default { } }, methods: { + escapeHtml(str) { + const el = document.createElement('span') + el.textContent = str + return el.innerHTML + }, leaveGroup() { this.$refs.confirmLeave.show() }, From bd93284c2f10ceed1edbf7f2cec4d9973e770b39 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Thu, 23 Apr 2026 09:07:43 -0700 Subject: [PATCH 08/12] fix(xss): add DOMPurify to ReadMore.vue as defense-in-depth Adds client-side HTML sanitization via DOMPurify to the ReadMore component, which renders group and event descriptions via v-html. Server-side Purify::clean() model mutators are the primary defense, but this provides a second layer in case any write path bypasses the model (raw DB queries, migrations, imports). --- package.json | 1 + resources/js/components/ReadMore.vue | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index ecb0d41f12..54643f4b06 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "bootstrap4-datetimepicker": "^5.2.3", "chart.js": "^2.7.2", "codemirror": "^5.39.2", + "dompurify": "^3.4.0", "dropzone": "^5.7.2", "ekko-lightbox": "^5.3.0", "floatthead": "^2.1.2", diff --git a/resources/js/components/ReadMore.vue b/resources/js/components/ReadMore.vue index a0111f3dff..61115143b8 100644 --- a/resources/js/components/ReadMore.vue +++ b/resources/js/components/ReadMore.vue @@ -4,10 +4,10 @@

- + - - + + @@ -20,6 +20,7 @@