Area: Account & identity (audit phase 1) · Surface: GET /members (MemberDirectoryController@index) · Dimension: security · Severity: major
OWASP A01:2021 Broken Access Control (function/data-level privacy bypass). Users can mark their real name (`profile_field_visibility['real_name']`) and location (`profile_field_visibility['country']` plus the `profile_display['show_location']` toggle) as `private` or `friends_only`. Those controls are enforced on /profile/{username} but are completely ignored by /members. Any authenticated member can therefore (1) see the real name and city/country of users who explicitly set them to private, and (2) enumerate/target users by location via the `?location=` filter and by real name via the `?q=` search (which matches first_name/last_name, MemberDirectoryController.php:27). This defeats a privacy control the product advertises in account settings — a user who set location to 'private' is still findable and viewable in the directory.
Evidence
MemberDirectoryController::index selects and exposes private fields with NO privacy gate:
platform/src/Controllers/MemberDirectoryController.php:38-44 — location filter applied to ALL users:
```php
if ($locationFilter !== '') {
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $locationFilter);
$where[] = '(u.country LIKE :loc1 OR u.city LIKE :loc2 OR u.state_province LIKE :loc3)';
```
platform/src/Controllers/MemberDirectoryController.php:59-67 — selects city/country/first_name/last_name for every member with no visibility check.
Template renders them unconditionally:
platform/templates/members/index.php:43 `$displayName = $dn($m);` (UsernameHelper::displayName returns first_name+last_name when set — src/Helpers/UsernameHelper.php:53-56)
platform/templates/members/index.php:44,57-58 renders city/country.
Contrast with the profile page, which DOES gate these exact fields:
platform/templates/profile/show.php:32-40 `$canSeeField` returns false for 'private', and only true for 'friends_only' when `$friendshipStatus === 'accepted'`; line 340 gates location on `($display['show_location'] || $showOwnerExtras) && $canSeeField('country')`; line 100 gates real name on `$canSeeField('real_name')`.
The directory controller never reads `profile_field_visibility` or `profile_display` (grep returns nothing). These are the same toggles users set via AccountController::updateDisplayPreferences (src/Controllers/AccountController.php:438-444, 'field_visibility_real_name'/'country' = public|friends_only|private).
Suggested fix. In MemberDirectoryController::index, decode each member's profile_field_visibility + profile_display and null out first_name/last_name and city/country for the viewer unless the viewer is the owner, an admin (role>=4), the field is 'public', or 'friends_only' with an accepted friendship — mirroring templates/profile/show.php's $canSeeField. For the location and name search/filter clauses, additionally constrain matches to rows the viewer is allowed to see those fields on (e.g. only filter by location for users whose effective country visibility is public, or who are friends of the viewer), so the search cannot be used as a private-data oracle. Best done by extracting the profile $canSeeField logic into a shared helper used by both surfaces.
Filed by the automated tenant-app audit (phase 1) and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus