Info disclosure: public /offerings endpoint leaks etransfer_email #32

Closed
opened 2026-06-09 19:08:17 +00:00 by thatguygriff · 2 comments
Owner

Severity: Medium — unauthenticated PII / payment-destination disclosure.

Problem

GET /wp-json/us-scheduler/v1/offerings has permission_callback => '__return_true' (src/Offering/OfferingEndpoint.php:16-31) and Offering::toArray() includes etransfer_email (src/Offering/Offering.php:89).

Impact

Any anonymous visitor can retrieve every offering's e-transfer destination email. This is:

  • PII harvesting (instructor/studio email addresses), and
  • Disclosure of exact payment-destination addresses, enabling e-transfer interception / social-engineering ("the studio changed its e-transfer address").

Fix

Strip etransfer_email from the public serialization. It is only needed in the authenticated createIntent response (where it already appears). Either drop the field from toArray() and add it only where authorized, or gate the field behind login.

**Severity: Medium** — unauthenticated PII / payment-destination disclosure. ## Problem `GET /wp-json/us-scheduler/v1/offerings` has `permission_callback => '__return_true'` ([src/Offering/OfferingEndpoint.php:16-31](src/Offering/OfferingEndpoint.php#L16)) and `Offering::toArray()` includes `etransfer_email` ([src/Offering/Offering.php:89](src/Offering/Offering.php#L89)). ## Impact Any anonymous visitor can retrieve every offering's e-transfer destination email. This is: - PII harvesting (instructor/studio email addresses), and - Disclosure of exact **payment-destination** addresses, enabling e-transfer interception / social-engineering ("the studio changed its e-transfer address"). ## Fix Strip `etransfer_email` from the public serialization. It is only needed in the authenticated `createIntent` response (where it already appears). Either drop the field from `toArray()` and add it only where authorized, or gate the field behind login.
thatguygriff added the paymentssecurity labels 2026-06-09 19:08:17 +00:00
Author
Owner

Resolution — gated behind auth rather than just stripping the field.

Investigation confirmed there is no anonymous consumer of the public listing endpoints. Both front-end pages (BookingPage, GroupClassPage) hard-gate on is_user_logged_in() + book_lesson before their JS loads, and the JS only calls REST with an X-WP-Nonce from a logged-in session. Admin management uses repositories directly, not these GETs.

So instead of only removing etransfer_email from the public output, the read endpoints now require the booking capability (matching AvailabilityEndpoint::index) — removing the disclosure surface entirely:

  • OfferingEndpoint::indexcanBook (was __return_true)
  • QuestionEndpoint::indexcanBook (closes the public registration-questions exposure too)
  • PolicyEndpoint::indexcanBook (the signup gate renders its policies server-side, not via REST)

Defense-in-depth retained: Offering::toArray() still omits etransfer_email from the listing (includeEtransferEmail: false); the relevant address is delivered only via the authenticated createIntent response for the student's own registration.

composer test (200 tests), composer lint, composer cs all green.

**Resolution — gated behind auth rather than just stripping the field.** Investigation confirmed there is **no anonymous consumer** of the public listing endpoints. Both front-end pages (`BookingPage`, `GroupClassPage`) hard-gate on `is_user_logged_in()` + `book_lesson` before their JS loads, and the JS only calls REST with an `X-WP-Nonce` from a logged-in session. Admin management uses repositories directly, not these GETs. So instead of only removing `etransfer_email` from the public output, the read endpoints now require the booking capability (matching `AvailabilityEndpoint::index`) — removing the disclosure surface entirely: - `OfferingEndpoint::index` → `canBook` (was `__return_true`) - `QuestionEndpoint::index` → `canBook` (closes the public registration-questions exposure too) - `PolicyEndpoint::index` → `canBook` (the signup gate renders its policies server-side, not via REST) Defense-in-depth retained: `Offering::toArray()` still omits `etransfer_email` from the listing (`includeEtransferEmail: false`); the relevant address is delivered only via the authenticated `createIntent` response for the student's own registration. `composer test` (200 tests), `composer lint`, `composer cs` all green.
Author
Owner

Verified resolved on main (061d09e, PR #38): GET /offerings now requires login + book_lesson (OfferingEndpoint::canBook()), and the listing serializes with Offering::toArray(includeEtransferEmail: false) so the e-transfer destination is omitted. It is only exposed in the authenticated payments/intent response for the paying student. Re-confirmed during the 2026-06-10 security review pass.

Verified resolved on main (061d09e, PR #38): GET /offerings now requires login + book_lesson (OfferingEndpoint::canBook()), and the listing serializes with Offering::toArray(includeEtransferEmail: false) so the e-transfer destination is omitted. It is only exposed in the authenticated payments/intent response for the paying student. Re-confirmed during the 2026-06-10 security review pass.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Unsupervised/unsupervised-scheduler#32