diff --git a/app/Group.php b/app/Group.php
index d61feb3f5c..1a2dbebb32 100644
--- a/app/Group.php
+++ b/app/Group.php
@@ -538,6 +538,16 @@ public function setDistanceAttribute($val)
$this->distance = $val;
}
+ /**
+ * The group description is Quill-authored HTML which we render unescaped, so it has to
+ * be sanitised. Doing it in the mutator rather than in the controllers means every
+ * write path - v2 API, web forms, imports, seeders - is covered by one rule.
+ */
+ public function setFreeTextAttribute($val)
+ {
+ $this->attributes['free_text'] = is_null($val) ? null : \Stevebauman\Purify\Facades\Purify::clean($val);
+ }
+
public function createDiscourseGroup() {
// Get the host who created the group.
$success = false;
diff --git a/app/GroupTags.php b/app/GroupTags.php
index 0fb7af1294..add7c175db 100644
--- a/app/GroupTags.php
+++ b/app/GroupTags.php
@@ -23,6 +23,14 @@ class GroupTags extends Model
*/
protected $fillable = ['tag_name', 'description', 'network_id'];
+ /**
+ * Tag descriptions are rendered with v-html in NetworkPage.vue, so sanitise on write.
+ */
+ public function setDescriptionAttribute($val)
+ {
+ $this->attributes['description'] = is_null($val) ? null : \Stevebauman\Purify\Facades\Purify::clean($val);
+ }
+
/**
* The attributes that should be hidden for arrays.
*
diff --git a/app/Http/Controllers/API/DeviceController.php b/app/Http/Controllers/API/DeviceController.php
index 2fed4219d8..0eada58fcb 100644
--- a/app/Http/Controllers/API/DeviceController.php
+++ b/app/Http/Controllers/API/DeviceController.php
@@ -368,9 +368,19 @@ public function updateDevicev2(Request $request, $iddevices): JsonResponse
) = $this->validateDeviceParams($request,false);
$event = Party::findOrFail($eventid);
+ $device = Device::findOrFail($iddevices);
- if (!Fixometer::userHasEditEventsDevicesPermission($eventid, $user->id)) {
- // Only hosts can add devices to events.
+ // Authorise against the event the device actually belongs to. Checking only the
+ // eventid from the request body would let a host of any event edit any device in
+ // the database just by naming their own event here - deleteDevicev2 below already
+ // derives the event from the record for this reason.
+ if (!Fixometer::userHasEditEventsDevicesPermission($device->event, $user->id)) {
+ // Only hosts can edit devices on events.
+ abort(403);
+ }
+
+ // Moving a device to a different event needs rights on the destination too.
+ if ($eventid != $device->event && !Fixometer::userHasEditEventsDevicesPermission($eventid, $user->id)) {
abort(403);
}
@@ -393,7 +403,6 @@ public function updateDevicev2(Request $request, $iddevices): JsonResponse
'repaired_by' => $user->id,
];
- $device = Device::findOrFail($iddevices);
$device->update($data);
event(new DeviceCreatedOrUpdated($device));
diff --git a/app/Http/Controllers/API/EventController.php b/app/Http/Controllers/API/EventController.php
index 93907489b5..4bce981963 100644
--- a/app/Http/Controllers/API/EventController.php
+++ b/app/Http/Controllers/API/EventController.php
@@ -292,6 +292,16 @@ public function getEventv2(Request $request, $idevents)
{
$party = Party::findOrFail($idevents);
+ // Events on approved groups are public, but an event on a group still awaiting
+ // moderation is not - and the resource returns its exact lat/lng, which for a
+ // pending group may be someone's home address. This is the same rule the web
+ // event page applies; getEventsForGroupv2 already gates on the group too.
+ $user = Auth::user() ?: auth('api')->user();
+
+ if (! Fixometer::userHasViewPartyPermission($idevents, $user ? $user->id : null, $party)) {
+ abort(404);
+ }
+
return \App\Http\Resources\Party::make($party);
}
@@ -472,7 +482,16 @@ public function createEventv2(Request $request): JsonResponse
$autoapprove = $group->auto_approve;
if (!Fixometer::userCanCreateEvents($user)) {
- // TODO: This doesn't check that they are a host of this particular group.
+ abort(403);
+ }
+
+ // Being a host of *some* group is not enough - the event has to be on a group you
+ // have authority over. Without this, any host could attach events to any group,
+ // and because event visibility is inherited from the group's approved flag those
+ // events go public with no moderation. Admins are covered by
+ // userHasEditGroupPermission; network coordinators are checked separately because
+ // their authority comes from the group's networks rather than users_groups.
+ if (!Fixometer::userHasEditGroupPermission($groupid, $user->id) && !$user->isCoordinatorForGroup($group)) {
abort(403);
}
diff --git a/app/Http/Controllers/CalendarEventsController.php b/app/Http/Controllers/CalendarEventsController.php
index 8bad356db3..9fae161a27 100644
--- a/app/Http/Controllers/CalendarEventsController.php
+++ b/app/Http/Controllers/CalendarEventsController.php
@@ -112,7 +112,7 @@ public function allEventsByArea(Request $request, $area)
public function allEvents(Request $request, $env_hash)
{
- if ($env_hash != env('CALENDAR_HASH')) {
+ if ($env_hash !== env('CALENDAR_HASH')) {
return abort(404);
}
diff --git a/app/Http/Controllers/DeviceController.php b/app/Http/Controllers/DeviceController.php
index 2c0e359826..f68d119897 100644
--- a/app/Http/Controllers/DeviceController.php
+++ b/app/Http/Controllers/DeviceController.php
@@ -67,6 +67,19 @@ public function index($search = null): \Illuminate\View\View
public function imageUpload(Request $request, $id)
{
+ // Same rule as deleteImage below. Only an existing device can be checked - a
+ // device that hasn't been created yet has no event to authorise against, which
+ // is the accepted tradeoff already documented there.
+ if ($id > 0) {
+ $user = Auth::user();
+ $event_id = Device::findOrFail($id)->event;
+ $in_event = EventsUsers::where('event', $event_id)->where('user', $user->id)->first();
+
+ if (! Fixometer::hasRole($user, 'Administrator') && ! is_object($in_event)) {
+ abort(403);
+ }
+ }
+
try {
$images = [];
diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php
index 4dc9c9ffb5..2a48b2dbfd 100644
--- a/app/Http/Controllers/ExportController.php
+++ b/app/Http/Controllers/ExportController.php
@@ -72,7 +72,8 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL)
}
$filename .= '.csv';
- $file = fopen(base_path() . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $filename, 'w+');
+ $fullpath = $this->exportPath($filename);
+ $file = fopen($fullpath, 'w+');
$me = auth()->user();
@@ -121,7 +122,7 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL)
}
}
- fputcsv($file, [
+ fputcsv($file, $this->csvSafeRow([
$device->item_type,
$device->deviceCategory->name,
$device->brand,
@@ -135,7 +136,7 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL)
$wasteImpact,
$co2Diverted,
$device->deviceCategory->powered ? 'Powered' : 'Unpowered'
- ]);
+ ]));
}
}
@@ -145,7 +146,7 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL)
'Content-Type' => 'text/csv',
];
- return Response::download(base_path() . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $filename, $filename, $headers);
+ return Response::download($fullpath, $filename, $headers)->deleteFileAfterSend(true);
}
/**
@@ -220,11 +221,12 @@ private function exportEvents($parties) {
// write content to file
$filename = 'events.csv';
- $file = fopen($filename, 'w+');
+ $fullpath = $this->exportPath($filename);
+ $file = fopen($fullpath, 'w+');
fputcsv($file, $headers);
foreach ($PartyArray as $d) {
- fputcsv($file, $d);
+ fputcsv($file, $this->csvSafeRow($d));
}
fclose($file);
@@ -232,6 +234,37 @@ private function exportEvents($parties) {
'Content-Type' => 'text/csv',
];
- return Response::download($filename, $filename, $headers);
+ return Response::download($fullpath, $filename, $headers)->deleteFileAfterSend(true);
+ }
+
+ /**
+ * Somewhere to build an export that is not served by the webserver. These files were
+ * previously written into public/ under a name derived from the group or venue, where
+ * they persisted and could be fetched by anyone who guessed the name.
+ */
+ private function exportPath($filename)
+ {
+ $dir = storage_path('app' . DIRECTORY_SEPARATOR . 'exports');
+
+ if (! is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ return $dir . DIRECTORY_SEPARATOR . $filename;
+ }
+
+ /**
+ * Spreadsheets treat a cell starting with =, +, - or @ as a formula, so prefix those
+ * with an apostrophe. Device fields are free text entered at events.
+ */
+ private function csvSafeRow(array $row)
+ {
+ return array_map(function ($value) {
+ if (is_string($value) && $value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
+ return "'" . $value;
+ }
+
+ return $value;
+ }, $row);
}
}
diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php
index 7ae9c17d39..deb7f86eb8 100644
--- a/app/Http/Controllers/GroupController.php
+++ b/app/Http/Controllers/GroupController.php
@@ -384,7 +384,7 @@ public function postSendInvite(Request $request): RedirectResponse
// Don't log to Sentry - legitimate user error.
return redirect()->back()->with('warning', __('groups.invite_success_apart_from', [
- 'emails' => rtrim(implode(', ', $not_sent))
+ 'emails' => e(rtrim(implode(', ', $not_sent)))
]));
}
@@ -473,7 +473,7 @@ public function delete($id): RedirectResponse
return redirect('/user/forbidden');
} else {
return redirect('/group')->with('success', __('groups.delete_succeeded', [
- 'name' => $name,
+ 'name' => e($name),
]));
}
} else {
@@ -602,7 +602,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) {
@@ -615,6 +615,14 @@ public function getJoinGroup($group_id): RedirectResponse
public function imageUpload(Request $request, $id)
{
+ // Same rule as ajaxDeleteImage below - uploading replaces the group's existing
+ // image, so it needs the same authority as deleting it.
+ $user = Auth::user();
+
+ if (! Fixometer::hasRole($user, 'Administrator') && ! Fixometer::userHasEditGroupPermission($id, $user->id)) {
+ abort(403);
+ }
+
try {
if (isset($_FILES) && ! empty($_FILES)) {
$existing_image = Fixometer::hasImage($id, 'groups', true);
diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php
index ef39106cce..ef269b8022 100644
--- a/app/Http/Controllers/NetworkController.php
+++ b/app/Http/Controllers/NetworkController.php
@@ -136,6 +136,14 @@ public function update(Request $request, Network $network): RedirectResponse
->withWarning('Image uploads are disabled on this site.');
}
+ // This is the one upload path that doesn't go through FixometerFile, which
+ // restricts uploads to jpg/png/gif by sniffing the content. Without a rule
+ // here the extension comes from whatever mime type is detected, so an SVG -
+ // which can carry script and is served from our own origin - would be stored.
+ $request->validate([
+ 'network_logo' => 'image|mimes:jpeg,jpg,png,gif|max:5120',
+ ]);
+
// Determine the correct disk to use (s3 on Fly, public_uploads in dev)
$disk = config('filesystems.default') === 's3' ? 's3' : 'public_uploads';
diff --git a/app/Http/Controllers/PartyController.php b/app/Http/Controllers/PartyController.php
index da01ced133..60dd80d009 100644
--- a/app/Http/Controllers/PartyController.php
+++ b/app/Http/Controllers/PartyController.php
@@ -746,6 +746,15 @@ public function cancelInvite($event_id): RedirectResponse
public function imageUpload(Request $request, $id)
{
+ // Same rule as deleteImage below: you must be an admin or have some
+ // involvement in the event to attach photos to it.
+ $user = Auth::user();
+ $in_event = EventsUsers::where('event', $id)->where('user', $user->id)->first();
+
+ if (! Fixometer::hasRole($user, 'Administrator') && ! is_object($in_event)) {
+ abort(403);
+ }
+
try {
if (empty($_FILES) && ! empty($request->files)) {
// Shim to handle uploads from Tests
diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php
index 6f98bd2ee0..8987c41fea 100644
--- a/app/Http/Controllers/RoleController.php
+++ b/app/Http/Controllers/RoleController.php
@@ -55,9 +55,8 @@ public function edit($id, Request $request): View
if ($request->getMethod() == 'POST') {
$permissions = $request->get('permissions');
- $formid = (int) substr(strrchr($request->get('formId'), '_'), 1);
- $update = $role->edit($formid, $permissions);
+ $update = $role->edit($id, $permissions);
if (! $update) {
$response['danger'] = 'Something went wrong. Could not update the permissions.';
\Sentry\CaptureMessage($response['danger']);
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index 7368f67b62..e31579371d 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -197,10 +197,11 @@ public function postProfilePasswordEdit(Request $request): RedirectResponse
$user->setPassword(Hash::make($request->input('new-password')));
$user->save();
- $user->update([
- 'recovery' => substr(bin2hex(openssl_random_pseudo_bytes(32)), 0, 24),
- 'recovery_expires' => strftime('%Y-%m-%d %X', time() + (24 * 60 * 60)),
- ]);
+ // Changing your password used to mint a fresh 24 hour recovery token as a side
+ // effect, which left a live password-reset code on every account that had ever
+ // changed its password. Rotate the API token instead, so a token stolen before
+ // the change stops working.
+ $user->rotateAPIToken();
event(new PasswordChanged($user, $oldPassword));
@@ -283,7 +284,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');
@@ -451,12 +452,17 @@ public function reset(Request $request)
$User = new User;
$user = null;
- $recovery = $request->recovery;
+ $recovery = $request->input('recovery');
- if (!$recovery) {
+ // Only a plain, non-empty string can be a recovery code. Anything else - an array
+ // in particular - must be rejected before it reaches the query. filter_var()
+ // returns false for an array, Laravel binds false as the integer 0, and MySQL
+ // then compares the VARCHAR column to 0 numerically, which coerces every
+ // non-numeric token in the table to 0 and matches a real user's live code.
+ if (!is_string($recovery) || $recovery === '') {
+ $recovery = null;
$valid_code = false;
} else {
- $recovery = filter_var($recovery, FILTER_SANITIZE_STRING);
$user = $User->where('recovery', '=', $recovery)->first();
if (is_object($user) && strtotime($user->recovery_expires) > time()) {
@@ -483,11 +489,19 @@ public function reset(Request $request)
$email = $user->email;
$oldPassword = $user->password;
+ // Spend the recovery code as part of the same update, so a link can't be
+ // replayed for the rest of its 24 hour window.
$update = $user->update([
'password' => Hash::make($pwd),
+ 'recovery' => null,
+ 'recovery_expires' => null,
]);
if ($update) {
+ // A password reset is the remedy for a compromised account, so it has to
+ // invalidate the API token too - otherwise a stolen token outlives it.
+ $user->rotateAPIToken();
+
event(new PasswordChanged($user, $oldPassword));
return redirect('login')->with('success', __('passwords.updated'));
} else {
diff --git a/app/Http/Middleware/AcceptUserInvites.php b/app/Http/Middleware/AcceptUserInvites.php
index 25b1d42cf7..4658374340 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/app/Http/Middleware/EnsureAPIToken.php b/app/Http/Middleware/EnsureAPIToken.php
index 6be9294091..909c770e16 100644
--- a/app/Http/Middleware/EnsureAPIToken.php
+++ b/app/Http/Middleware/EnsureAPIToken.php
@@ -24,7 +24,7 @@ public function handle(Request $request, Closure $next): Response
$response = $next($request);
if (method_exists($response, 'withCookie')) {
- $response = $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, false, false));
+ $response = $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, $request->secure(), false));
}
return $response;
diff --git a/app/Network.php b/app/Network.php
index 231e3c4d2f..eb4069c18a 100644
--- a/app/Network.php
+++ b/app/Network.php
@@ -12,6 +12,15 @@ class Network extends Model
{
use HasFactory;
+ /**
+ * The network description is rendered with v-html in NetworkPage.vue, so sanitise it
+ * on write for the same reason as Group::setFreeTextAttribute().
+ */
+ public function setDescriptionAttribute($val)
+ {
+ $this->attributes['description'] = is_null($val) ? null : \Stevebauman\Purify\Facades\Purify::clean($val);
+ }
+
/**
* Get tags belonging to this network.
*/
diff --git a/app/Party.php b/app/Party.php
index cfc6a8a4fd..ac9ab31e9a 100644
--- a/app/Party.php
+++ b/app/Party.php
@@ -774,6 +774,15 @@ public function setEventEndUtcAttribute($val) {
$this->attributes['event_end_utc'] = $dt->toDateTimeString();
}
+ /**
+ * The event description is Quill-authored HTML which we render unescaped - on a public
+ * page - so it has to be sanitised. Done in the mutator so every write path is
+ * covered by one rule.
+ */
+ public function setFreeTextAttribute($val) {
+ $this->attributes['free_text'] = is_null($val) ? null : \Stevebauman\Purify\Facades\Purify::clean($val);
+ }
+
// 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/Services/CheckAuthService.php b/app/Services/CheckAuthService.php
deleted file mode 100644
index 67b3648f35..0000000000
--- a/app/Services/CheckAuthService.php
+++ /dev/null
@@ -1,99 +0,0 @@
-edit_profile_link = url('/profile/edit/');
-
- $this->menu = collect([
- 'general' => collect([]),
- 'reporting' => collect([]),
- 'user' => collect([]),
- ]);
-
- if (Cookie::get('authenticated')) {
- $this->handle(Cookie::get('authenticated'));
- }
-
- $this->menu->get('general')->put(Lang::get('general.about_page'), Lang::get('general.about_page_url'));
- $this->menu->get('general')->put(Lang::get('general.guidelines_page'), Lang::get('general.guidelines_page_url'));
- $this->menu->get('general')->put(Lang::get('general.privacy_page'), Lang::get('general.privacy_page_url'));
- $this->menu->get('general')->put(Lang::get('general.menu_help_feedback'), Lang::get('general.help_feedback_url'));
- $this->menu->get('general')->put(Lang::get('general.menu_help_feedback'), Lang::get('general.help_feedback_url'));
- $this->menu->get('general')->put(Lang::get('general.menu_faq'), Lang::get('general.faq_url'));
- $this->menu->get('general')->put(Lang::get('general.therestartproject'), Lang::get('general.restartproject_url'));
- }
-
- private function handle($email)
- {
- $this->user = User::where('email', $email)->first();
-
- if (! $this->user) {
- return false;
- }
-
- $this->authenticated = true;
- $this->edit_profile_link = $this->edit_profile_link.$this->user->id;
-
- if ($this->is_host || $this->is_admin) {
- $this->menu->get('reporting')->put('header', 'Reporting');
-
- $this->menu->get('reporting')->put(Lang::get('general.party_reporting'), url('search'));
-
- $this->menu->get('reporting')->put('reporting_spacer', 'spacer');
- }
- }
-
- /**
- * Transform the resource into an array.
- */
- public function toArray(Request $request): array
- {
- return [
- 'authenticated' => $this->authenticated,
- 'edit_profile_link' => $this->edit_profile_link,
- 'is_admin' => $this->is_admin,
- 'menu' => $this->menu->toArray(),
- ];
- }
-}
diff --git a/app/User.php b/app/User.php
index cc03afc130..10ef4c7a01 100644
--- a/app/User.php
+++ b/app/User.php
@@ -585,6 +585,22 @@ public function ensureAPIToken()
return $api_token;
}
+ /**
+ * Issue a new API token, invalidating the previous one.
+ *
+ * The token is a bearer credential for the whole API and is readable by the client's
+ * JavaScript, so anything that revokes access to the account - a password change or
+ * reset - has to replace it as well.
+ */
+ public function rotateAPIToken()
+ {
+ $api_token = \Illuminate\Support\Str::random(60);
+ $this->api_token = $api_token;
+ $this->save();
+
+ return $api_token;
+ }
+
public function notifications()
{
return $this->morphMany(
diff --git a/composer.json b/composer.json
index 08ef157820..dac1f7f737 100644
--- a/composer.json
+++ b/composer.json
@@ -41,6 +41,7 @@
"spatie/calendar-links": "^1.6",
"spatie/laravel-validation-rules": "^3.4",
"spinen/laravel-discourse-sso": "^2.8",
+ "stevebauman/purify": "^6.0",
"symfony/http-client": "^6.2",
"symfony/http-foundation": "^6.2",
"symfony/mailgun-mailer": "^6.2",
diff --git a/composer.lock b/composer.lock
index 1903d38c14..d078b4c20f 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": "bc96c0e7ddaad3d938052527512d5360",
+ "content-hash": "b47f3e23a394eb8308341eb299c09e40",
"packages": [
{
"name": "addwiki/mediawiki-api",
@@ -1615,6 +1615,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.4",
@@ -6999,6 +7060,72 @@
},
"time": "2024-04-14T21:40:02+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.28.0",
diff --git a/config/purify.php b/config/purify.php
new file mode 100644
index 0000000000..f1d9e1cda7
--- /dev/null
+++ b/config/purify.php
@@ -0,0 +1,114 @@
+,
+|