Skip to content

Fix volunteer PII leak on public event pages#872

Closed
edwh wants to merge 12 commits into
developfrom
fix/event-page-user-pii-leak
Closed

Fix volunteer PII leak on public event pages#872
edwh wants to merge 12 commits into
developfrom
fix/event-page-user-pii-leak

Conversation

@edwh

@edwh edwh commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Party::expandVolunteers() was serialising the full User Eloquent model into page HTML via json_encode. Any visitor to a public event page could read api_token, calendar_hash, latitude, longitude, recovery, and mediawiki tokens directly from the page source — and use the api_token to authenticate as that volunteer.
  • User::$hidden only contained password and remember_token, leaving all other sensitive fields unprotected in any serialisation.
  • ApiController::getUserInfo() was calling makeHidden('api_token') but leaving calendar_hash, recovery, etc. visible.

Changes

  1. app/User.php — extend $hidden to include api_token, calendar_hash, recovery, recovery_expires, mediawiki, latitude, longitude. Defence-in-depth: protects every serialisation point, not just the one we know about.
  2. app/Party.php expandVolunteers() — replace the full User model assignment with an explicit whitelist of only the fields the frontend needs: id, name, optionally email, and the user_skills shape { skill_name: { skill_name: "…" } }. The frontend only accesses volunteer.name and volunteer.user_skills — no other User fields are consumed.
  3. app/Http/Controllers/ApiController.php — extend the belt-and-suspenders makeHidden() call to cover all credential fields.

Tests

tests/Feature/Events/VolunteerPiiTest.php (3 tests, 26 assertions):

  • testEventPageDoesNotLeakVolunteerCredentials — loads the public event page HTML with a known sentinel api_token and asserts it is absent, while the volunteer's name is present (sanity check that volunteers are still shown).
  • testUserModelHiddenFieldsExcludeCredentials — asserts every sensitive field key is absent from User::toArray().
  • testExpandVolunteersEmitsSafeShape — asserts expandVolunteers() returns a plain array (not an Eloquent model) and contains none of the sensitive keys.

Test plan

  • All 3 VolunteerPiiTest tests pass (OK (3 tests, 26 assertions) confirmed locally)
  • View a public event page in dev — attendee names and skills still visible
  • View page source — no api_token, calendar_hash, coordinates visible

🤖 Generated with Claude Code

edwh and others added 10 commits May 27, 2026 12:12
PR #744 changed GroupsTable's prop API from `groups` (an array of group
objects) to `groupids` (an array of group ids), but the migration of
GroupsRequiringModeration was left half-finished: the template had a
literal 'TODO' string visible to admins / NCs and the original
`<GroupsTable :groups="groups" approve />` was commented out next to it.

Render the table with `:groupids="groupIds" :approve="true"` and add a
computed `groupIds` derived from the moderation store (which uses
`idgroups`, but fall back to `id` for safety).

Tests cover:
- renders GroupsTable (not literal TODO) when groups exist
- renders nothing when there are no groups
- filters by the networks prop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…le comment

Three follow-ups to the GroupsRequiringModeration fix:

1. GroupsPage forwards showTags / networks / allGroupTags through to its
   inner GroupsTable on the 'Your groups' tab so tag badges actually
   render there (they didn't — the props were received from the blade
   but dropped on the floor). Also fixes a `your-area="yourArea"`
   literal-string bug (now `:your-area="yourArea"`). Removes three
   genuinely dead props (yourLat, yourLng, userId) and the matching
   blade attributes. Resolves the TODO at GroupsPage.vue:118. Added
   GroupsPage.test.js with 2 cases.

2. groups/fetch action now de-dups concurrent in-flight requests for
   the same (id, includeStats) key — GroupsPage's mounted() loop fires
   one fetch per yourGroup in parallel, so without this the same group
   could be in flight twice. Implemented as a module-scope Map; entry
   is cleared in a `finally` so a rejection doesn't poison the slot.
   Resolves the TODO at groups.js:227. Added groups.test.js with 5
   cases (concurrent same-id, sequential, concurrent different-ids,
   error-then-retry, different includeStats).

3. Removed the stale 'See TODO below to chase the underlying
   reactivity bug separately' comment in grouptags.test.js — the
   reactivity bug WAS chased (unhandled async lifecycle rejections
   leaking Vue 2's scheduler `pending` flag) and the fix shipped in
   this PR (try/catch on async mounted hooks + flushRender() helper).

Test infra: jest.config.json gets resources/js/store as a third root,
plus moduleNameMapper stubs for leaflet-control-geocoder and
vue-awesome so component tests can mount GroupsPage without resolving
its transitive Leaflet/Icon deps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…revert; keep PR content)

# Conflicts:
#	resources/js/components/GroupsPage.vue
#	resources/js/components/GroupsRequiringModeration.vue
#	resources/js/store/groups.js
#	tests/Integration/grouptags.test.js
The userId prop was removed from GroupsPage.vue and its blade view as
genuinely dead code while clearing PR #744 review TODOs. Update the test
contract to match.
GroupInfoModal.groupImage guarded on group.image but built the URL from
group.group_image, which the Group API resource never sets — so any group
with an image rendered /uploads/mid_undefined. Use group.image (the
back-filled groups.image column the resource exposes).

Found by adversarial review of PR #862.
The `hosts` and `restarters` columns rendered their text labels ("Hosts" /
"Restarters") instead of icons, while every other column showed an icon. The
b-table header-override slots were named `head(all_confirmed_hosts_count)` /
`head(all_confirmed_restarters_count)`, but the field keys are `hosts` /
`restarters`, so the slots never matched and b-table fell back to the labels.

Rename the slots to `head(hosts)` / `head(restarters)`.

Test (GroupsTable.test.js): asserts the host/restarter column headers render
the user / volunteer icons rather than text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When GroupMap is created inside a hidden tab (the groups page renders the map
tab eagerly), its Leaflet container is 0x0. On switching to the tab the
container resizes but nothing told Leaflet to re-measure, so tiles never filled
the now-visible area and most of the map showed as grey.

Observe the container with a ResizeObserver and call `invalidateSize()` (then
re-run `idle()` to recompute in-bounds groups) when it changes size.

Test (GroupMap.test.js): firing the resize observer calls invalidateSize().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The groups table filter bar (shown on the network page) referenced
groups.search_name_placeholder, search_location_placeholder,
search_country_placeholder, search_tags_placeholder, show_filters and
hide_filters, none of which existed in the lang files, so the UI rendered the
raw keys. Add them for en, fr and fr-BE.

Test (GroupFilterTranslationsTest.php): asserts the keys exist and are
non-empty in all three locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Party::expandVolunteers() was serialising the full User Eloquent model
into the page HTML (via json_encode in the Blade template). Because
User::$hidden only contained password and remember_token, any visitor
could read api_token, calendar_hash, latitude, longitude, recovery, and
mediawiki tokens directly from the page source.

Fixes:
1. User::$hidden — add api_token, calendar_hash, recovery,
   recovery_expires, mediawiki, latitude, longitude (defence-in-depth;
   protects every serialisation point).
2. Party::expandVolunteers() — replace the full User model assignment
   with an explicit whitelist of only the fields the frontend needs
   (id, name, email when showEmails, user_skills shape). Never put an
   Eloquent model directly into page-embedded JSON.
3. ApiController::getUserInfo() — extend the belt-and-suspenders
   makeHidden() call to cover all credential fields, not just api_token.

Tests (VolunteerPiiTest.php):
- testEventPageDoesNotLeakVolunteerCredentials: loads the public event
  page HTML and asserts sentinel values are absent while volunteer name
  is still present.
- testUserModelHiddenFieldsExcludeCredentials: asserts every sensitive
  field is in $hidden via User::toArray().
- testExpandVolunteersEmitsSafeShape: asserts expandVolunteers() returns
  a plain array (not an Eloquent model) and contains no sensitive keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

<!-- Styles -->
@vite(['resources/sass/app.scss', 'resources/global/css/app.scss'])
<link rel="stylesheet" href="https://unpkg.com/leaflet-control-geocoder/dist/Control.Geocoder.css" type="text/css" />
<!-- Styles -->
@vite(['resources/sass/app.scss', 'resources/global/css/app.scss'])
<link rel="stylesheet" href="https://unpkg.com/leaflet-control-geocoder/dist/Control.Geocoder.css" type="text/css" />
<link rel="stylesheet" href="//unpkg.com/leaflet-gesture-handling/dist/leaflet-gesture-handling.min.css" type="text/css">
edwh and others added 2 commits June 10, 2026 15:49
…ints

- Call unsetRelation('volunteer') before assigning the safe array; without
  this, Eloquent's toArray() merges relationsToArray() last, silently
  overwriting the safe array with the full User model in JSON output
- Add null guard for orphaned skill FK rows (deleted skillName record)
- Fix inverted $showEmails in EventController::listVolunteers — was
  negated so non-hosts received emails and hosts did not
- Add latitude/longitude to makeHidden() in ApiController::getUserInfo()
  and UserController::mapUserAndAuditToUserChange() (Zapier endpoint)
- Update testExpandVolunteersEmitsSafeShape to assert via json_encode so
  the Eloquent relation-bypass is actually caught (PHP array-access was
  a false pass — it reads $attributes, not the serialised output)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add api_token, calendar_hash, recovery, recovery_expires, latitude,
longitude, and password to User::$auditExclude so they are never
written to the audits table on any model update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@edwh

edwh commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favour of a clean branch rebased directly onto develop — the original branch was cut from a feature branch, so the diff included ~100 unrelated files. Replaced by #879

@edwh edwh closed this Jun 19, 2026
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot
4.3% Duplication on New Code (required ≤ 3%)
B Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants