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 @@ +, +| ' + . '
Styled
' + . '
'; + + $idgroups = $this->createGroup('Nasty Group', 'https://therestartproject.org', 'London', $nasty); + $stored = Group::findOrFail($idgroups)->free_text; + + foreach (['assertStringNotContainsString($needle, $stored, "Sanitiser kept $needle"); + } + + // ...while the surrounding legitimate content survives. + $this->assertStringContainsString('Text', $stored); + } +} diff --git a/tests/Feature/Users/PasswordResetHardeningTest.php b/tests/Feature/Users/PasswordResetHardeningTest.php new file mode 100644 index 0000000000..028729a5be --- /dev/null +++ b/tests/Feature/Users/PasswordResetHardeningTest.php @@ -0,0 +1,189 @@ +where('id', $user->id)->update([ + 'recovery' => $token, + 'recovery_expires' => date('Y-m-d H:i:s', time() + 3600), + ]); + } + + /** @test */ + public function array_recovery_code_cannot_reset_another_users_password(): void + { + $this->withExceptionHandling(); + + $victim = User::factory()->restarter()->create([ + 'password' => Hash::make('victim-original-password'), + ]); + + // A non-numeric token, which is what MySQL coerces to 0. Every user gets + // one of these at registration, so this is the normal state of the table. + $this->giveLiveRecoveryToken($victim, 'a3f5c1d9e7b2408f6a1c'); + + $originalHash = $victim->fresh()->password; + + // The exploit: recovery as an array makes filter_var() return false, + // which becomes the integer 0 in the SQL binding. + $this->post('/user/reset', [ + 'recovery' => ['1'], + 'password' => 'attacker-chosen', + 'confirm_password' => 'attacker-chosen', + ]); + + $this->assertEquals( + $originalHash, + $victim->fresh()->password, + 'An array recovery code must not match any user, let alone reset their password.' + ); + $this->assertFalse(Hash::check('attacker-chosen', $victim->fresh()->password)); + } + + /** @test */ + public function array_recovery_code_does_not_disclose_a_users_email(): void + { + $this->withExceptionHandling(); + + $victim = User::factory()->restarter()->create(); + $this->giveLiveRecoveryToken($victim, 'b7d2e4f6a8c0912e3b5d'); + + $response = $this->get('/user/reset?recovery[]=1'); + + $response->assertDontSee($victim->email, false); + } + + /** @test */ + public function recovery_code_is_single_use(): void + { + $this->withExceptionHandling(); + + $user = User::factory()->restarter()->create([ + 'password' => Hash::make('original'), + ]); + $token = 'c1d3e5f7a9b1234c5d6e'; + $this->giveLiveRecoveryToken($user, $token); + + // First use succeeds. + $this->post('/user/reset', [ + 'recovery' => $token, + 'password' => 'first-reset', + 'confirm_password' => 'first-reset', + ]); + $this->assertTrue(Hash::check('first-reset', $user->fresh()->password)); + + // The token must now be spent. + $this->assertNull($user->fresh()->recovery, 'recovery must be cleared after a successful reset'); + + $this->post('/user/reset', [ + 'recovery' => $token, + 'password' => 'second-reset', + 'confirm_password' => 'second-reset', + ]); + + $this->assertTrue( + Hash::check('first-reset', $user->fresh()->password), + 'A spent recovery token must not be reusable.' + ); + } + + /** @test */ + public function changing_your_password_does_not_mint_a_recovery_token(): void + { + $user = User::factory()->restarter()->create([ + 'password' => Hash::make('current-password'), + ]); + DB::table('users')->where('id', $user->id)->update([ + 'recovery' => null, + 'recovery_expires' => null, + ]); + + $this->actingAs($user); + + $this->post('/profile/edit-password', [ + 'id' => $user->id, + 'current-password' => 'current-password', + 'new-password' => 'brand-new-password', + 'new-password-repeat' => 'brand-new-password', + ]); + + $this->assertTrue(Hash::check('brand-new-password', $user->fresh()->password)); + $this->assertNull( + $user->fresh()->recovery, + 'A password change must not leave a live password-reset token behind.' + ); + } + + /** @test */ + public function api_token_is_rotated_when_the_password_is_reset(): void + { + $this->withExceptionHandling(); + + $user = User::factory()->restarter()->create([ + 'password' => Hash::make('original'), + ]); + $user->ensureAPIToken(); + $stolenToken = $user->fresh()->api_token; + $this->assertNotEmpty($stolenToken); + + $token = 'd2e4f6a8b0c2345d6e7f'; + $this->giveLiveRecoveryToken($user, $token); + + $this->post('/user/reset', [ + 'recovery' => $token, + 'password' => 'new-password', + 'confirm_password' => 'new-password', + ]); + + $this->assertNotEquals( + $stolenToken, + $user->fresh()->api_token, + 'Resetting the password must invalidate an API token that may have been stolen.' + ); + } + + /** @test */ + public function api_token_is_rotated_when_the_password_is_changed(): void + { + $user = User::factory()->restarter()->create([ + 'password' => Hash::make('current-password'), + ]); + $user->ensureAPIToken(); + $stolenToken = $user->fresh()->api_token; + + $this->actingAs($user); + + $this->post('/profile/edit-password', [ + 'id' => $user->id, + 'current-password' => 'current-password', + 'new-password' => 'brand-new-password', + 'new-password-repeat' => 'brand-new-password', + ]); + + $this->assertNotEquals($stolenToken, $user->fresh()->api_token); + } +} diff --git a/tests/Feature/Users/SkillsTest.php b/tests/Feature/Users/SkillsTest.php index 57283893a9..600110a9ab 100644 --- a/tests/Feature/Users/SkillsTest.php +++ b/tests/Feature/Users/SkillsTest.php @@ -87,11 +87,11 @@ public function testDelete(): void { 'description' => 'Planning', ]); - $response = $this->get('/skills/delete/' . $skill1->id); + $response = $this->post('/skills/delete/' . $skill1->id); $response->assertRedirect('/user/forbidden'); $this->loginAsTestUser(Role::ADMINISTRATOR); - $response = $this->get('/skills/delete/' . $skill1->id); + $response = $this->post('/skills/delete/' . $skill1->id); $response->assertSessionHas('success'); } } \ No newline at end of file diff --git a/tests/Integration/utils.js b/tests/Integration/utils.js index 05d0375cfd..c106a488f7 100644 --- a/tests/Integration/utils.js +++ b/tests/Integration/utils.js @@ -168,10 +168,16 @@ exports.createEvent = async function(page, baseURL, idgroups, past) { log('Setting event date', { past }) await page.click('#event_date button') + // The cell selector below picks a day late in the displayed month, so on the current + // month it can land in the past - "Invite volunteers" is gated on the event being + // upcoming, and that test failed on 30 July for an event the picker put on 26 July. + // Always move a month so the chosen day is unambiguously past or future. if (past) { log('Setting past date - going back a month') - // Go back a month await page.locator('[aria-label="Previous month"]').click() + } else { + log('Setting future date - going forward a month') + await page.locator('[aria-label="Next month"]').click() } await page.click('#event_date .b-calendar-grid > .b-calendar-grid-body > .row:last-child .btn:last-child') diff --git a/tests/__mocks__/resources/js/mixins/lang.js b/tests/__mocks__/resources/js/mixins/lang.js index eeff341467..54e81d5d13 100644 --- a/tests/__mocks__/resources/js/mixins/lang.js +++ b/tests/__mocks__/resources/js/mixins/lang.js @@ -43,6 +43,20 @@ export default { console.error("Missing translation " + key) return key } + }, + // Keep in step with the real mixin - components that interpolate database values + // into a v-html binding call this. + escapeHtml(value) { + if (value === null || value === undefined) { + return '' + } + + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') } } } \ No newline at end of file