Skip to content
6 changes: 0 additions & 6 deletions app/Http/Controllers/API/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,9 @@ protected static function getUserAudits($dateFrom = null)

protected static function mapUserAndAuditToUserChange($user, $audit)
{
// Hide fields not relevant for Zapier.
$user->makeHidden([
'updated_at',
'deleted_at',
'api_token',
'recovery',
'recovery_expires',
'calendar_hash',
'number_of_logins',
'consent_past_data',
'consent_gdpr',
'consent_future_data',
Expand Down
2 changes: 0 additions & 2 deletions app/Http/Controllers/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,6 @@ public static function getUserInfo(): JsonResponse
{
$user = Auth::user();

$user->makeHidden('api_token');

return response()->json($user->toArray());
}

Expand Down
32 changes: 20 additions & 12 deletions app/Models/Party.php
Original file line number Diff line number Diff line change
Expand Up @@ -824,21 +824,29 @@ public static function expandVolunteers($volunteers, $showEmails) {
$volunteer['fullName'] = $volunteer->getFullName();

if ($volunteer->volunteer) {
$volunteer['volunteer'] = $volunteer->volunteer;
$user = $volunteer->volunteer;

if (!$showEmails) {
$volunteer['volunteer']['email'] = NULL;
}

if (! empty($volunteer->volunteer)) {
$volunteer['userSkills'] = $volunteer->volunteer->userSkills->all();
$volunteer['profilePath'] = '/uploads/thumbnail_'.$volunteer->volunteer->getProfile($volunteer->volunteer->id)->path;
$volunteer['userSkills'] = $user->userSkills->all();
Comment thread
sctice-ifixit marked this conversation as resolved.
Outdated
$volunteer['profilePath'] = '/uploads/thumbnail_'.$user->getProfile($user->id)->path;

foreach ($volunteer['userSkills'] as $skill) {
// Force expansion
$skill->skillName->skill_name;
}
foreach ($volunteer['userSkills'] as $skill) {
$skill->skillName->skill_name;
}

// Drop the loaded Eloquent relation before overwriting it with the
// allowlist. Otherwise relationsToArray() re-injects the full User
// model during serialization, overriding the array set below and
// leaking the entire user record to public event pages.
$volunteer->unsetRelation('volunteer');
$volunteer['volunteer'] = [
'id' => $user->id,
'name' => $user->name,
'email' => $showEmails ? $user->email : null,
// The event page reads volunteer.user_skills (see
// EventAttendee.vue); keep it in the allowlist so the skills
// list still renders. Skill associations are not sensitive.
'user_skills' => $volunteer['userSkills'],
];
}

$ret[] = $volunteer;
Expand Down
11 changes: 10 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,16 @@ class User extends Authenticatable implements Auditable, HasLocalePreference
* @var array
*/
protected $hidden = [
'password', 'remember_token',
'password',
'remember_token',
'api_token',
'calendar_hash',
'recovery',
'recovery_expires',
'latitude',
'longitude',
'last_login_at',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up: latitude, longitude, and last_login_at weren't hidden in the Zapier changes path (mapUserAndAuditToUserChange hid only number_of_logins and the token fields), so they were present in that feed. They're now hidden globally. No evidence anything consumes them; flagging in case a Zap maps those fields.

'number_of_logins',
];

/**
Expand Down
156 changes: 156 additions & 0 deletions tests/Feature/Events/PublicEventDataLeakTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

namespace Tests\Feature;

use App\Models\EventsUsers;
use App\Models\Group;
use App\Models\Network;
use App\Models\Party;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class PublicEventDataLeakTest extends TestCase
{
private array $sensitiveFields = [
'api_token',
'calendar_hash',
'recovery',
'recovery_expires',
'latitude',
'longitude',
];

private function createEventWithVolunteer(array $userOverrides = []): array
{
Queue::fake();

// The group must be approved, otherwise events on it are not visible to
// anonymous visitors (PartyController::view aborts with 404) and the
// public-page leak assertions below would never execute.
$group = Group::factory()->create(['approved' => true]);
$network = Network::factory()->create();
$network->addGroup($group);

$event = Party::factory()->create([
'group' => $group,
'event_start_utc' => '2130-01-01T12:13:00+00:00',
'event_end_utc' => '2130-01-01T13:14:00+00:00',
]);

$volunteer = User::factory()->create(array_merge([
'api_token' => 'SENSITIVE_TOKEN_VALUE',
'calendar_hash' => 'SENSITIVE_CAL_HASH',
'latitude' => 51.5074,
'longitude' => -0.1278,
'recovery' => 'SENSITIVE_RECOVERY_TOKEN',
'recovery_expires' => now()->addHour(),
'username' => 'SENSITIVE_USERNAME',
'external_user_id' => 'SENSITIVE_EXT_ID',
], $userOverrides));

EventsUsers::create([
'event' => $event->idevents,
'user' => $volunteer->id,
'status' => 1,
'role' => Role::RESTARTER,
]);

return [$event, $volunteer];
}

private function assertNoSensitiveFields(string $content): void
{
foreach ($this->sensitiveFields as $field) {
$this->assertStringNotContainsString(
Comment thread
sctice-ifixit marked this conversation as resolved.
'"' . $field . '"',
$content,
"Sensitive field '$field' found in response"
);
}

$this->assertStringNotContainsString('SENSITIVE_TOKEN_VALUE', $content);
$this->assertStringNotContainsString('SENSITIVE_CAL_HASH', $content);
$this->assertStringNotContainsString('SENSITIVE_RECOVERY_TOKEN', $content);
}

/**
* Assert that every embedded "volunteer" object is limited to the
* {id, name, email} allowlist. The full User model used to leak here
* because a loaded Eloquent relation overrides the allowlist array
* during serialization, so checking only the $hidden fields above is
* not enough — username, external ids, location, age, consent dates,
* etc. would still be exposed.
*/
private function assertVolunteerObjectsAllowlistOnly(string $content): void
{
// Props are json_encode()d then HTML-escaped by Blade ({{ }}), so
// decode entities before extracting the embedded JSON.
$decoded = html_entity_decode($content, ENT_QUOTES);

$this->assertMatchesRegularExpression(
'/"volunteer":\{/',
$decoded,
'No volunteer object was rendered; the allowlist assertions would be vacuous'
);

preg_match_all('/"volunteer":(\{[^}]*\})/', $decoded, $matches);

foreach ($matches[1] as $json) {
$object = json_decode($json, true);
$this->assertIsArray($object, "Could not decode volunteer object: $json");

$keys = array_keys($object);
sort($keys);

// The test volunteer has no skills, so user_skills is an empty array
// and the volunteer object stays flat (the regex above assumes that).
$this->assertSame(
['email', 'id', 'name', 'user_skills'],
$keys,
'Volunteer object must be limited to the {id, name, email, user_skills} allowlist: ' . $json
);
}

// Sentinel PII values that must never reach any serialized response.
$this->assertStringNotContainsString('SENSITIVE_USERNAME', $content);
$this->assertStringNotContainsString('SENSITIVE_EXT_ID', $content);
}

public function testPublicEventPageDoesNotLeakSensitiveUserFields(): void
{
[$event, $volunteer] = $this->createEventWithVolunteer();

$response = $this->get('/party/view/' . $event->idevents);

$response->assertStatus(200);
$content = $response->getContent();

$this->assertNoSensitiveFields($content);
$this->assertVolunteerObjectsAllowlistOnly($content);

// Anonymous viewers must not see volunteer email addresses.
$this->assertStringNotContainsString(
$volunteer->email,
html_entity_decode($content, ENT_QUOTES),
'Volunteer email address exposed to an anonymous viewer'
);
}

public function testVolunteersApiDoesNotLeakSensitiveUserFields(): void
{
[$event] = $this->createEventWithVolunteer();

$admin = $this->createUserWithToken(Role::ADMINISTRATOR);
$this->actingAs($admin);

$response = $this->get('/api/events/' . $event->idevents . '/volunteers');

$response->assertStatus(200);
$content = $response->getContent();

$this->assertNoSensitiveFields($content);
$this->assertVolunteerObjectsAllowlistOnly($content);
}
}