-
Notifications
You must be signed in to change notification settings - Fork 0
Fix: Ensure Event/Volunteer Responses only Include Intended User Fields #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
753fc89
58a2e8e
75cb3a3
5f80692
6897d60
d0147dc
854bb56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Heads-up: |
||
| 'number_of_logins', | ||
| ]; | ||
|
|
||
| /** | ||
|
|
||
| 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( | ||
|
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); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.