Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions app/GroupTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
15 changes: 12 additions & 3 deletions app/Http/Controllers/API/DeviceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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));
Expand Down
21 changes: 20 additions & 1 deletion app/Http/Controllers/API/EventController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/CalendarEventsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
13 changes: 13 additions & 0 deletions app/Http/Controllers/DeviceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand Down
47 changes: 40 additions & 7 deletions app/Http/Controllers/ExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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,
Expand All @@ -135,7 +136,7 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL)
$wasteImpact,
$co2Diverted,
$device->deviceCategory->powered ? 'Powered' : 'Unpowered'
]);
]));
}
}

Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -220,18 +221,50 @@ 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);

$headers = [
'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);
}
}
14 changes: 11 additions & 3 deletions app/Http/Controllers/GroupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
]));
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions app/Http/Controllers/NetworkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
9 changes: 9 additions & 0 deletions app/Http/Controllers/PartyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions app/Http/Controllers/RoleController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>not</strong> update the permissions.';
\Sentry\CaptureMessage($response['danger']);
Expand Down
30 changes: 22 additions & 8 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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()) {
Expand All @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions app/Http/Middleware/AcceptUserInvites.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a class="plain-link" href='.url("/group/view/{$group->idgroups}").">{$group->name}</a>");
$request->session()->push('invites-feedback', 'You are already a member of <a class="plain-link" href="'.url("/group/view/{$group->idgroups}").'">'.e($group->name).'</a>');
}
}
$request->session()->forget('groups');
Expand All @@ -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 <a class="plain-link" href='.url("/party/view/{$event->idevents}").">{$event->venue}</a>");
$request->session()->push('invites-feedback', 'You are already a member of <a class="plain-link" href="'.url("/party/view/{$event->idevents}").'">'.e($event->venue).'</a>');
}
}
$request->session()->forget('events');
Expand Down
Loading