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.
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:
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.
**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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Severity: Medium — unauthenticated PII / payment-destination disclosure.
Problem
GET /wp-json/us-scheduler/v1/offeringshaspermission_callback => '__return_true'(src/Offering/OfferingEndpoint.php:16-31) andOffering::toArray()includesetransfer_email(src/Offering/Offering.php:89).Impact
Any anonymous visitor can retrieve every offering's e-transfer destination email. This is:
Fix
Strip
etransfer_emailfrom the public serialization. It is only needed in the authenticatedcreateIntentresponse (where it already appears). Either drop the field fromtoArray()and add it only where authorized, or gate the field behind login.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 onis_user_logged_in()+book_lessonbefore their JS loads, and the JS only calls REST with anX-WP-Noncefrom a logged-in session. Admin management uses repositories directly, not these GETs.So instead of only removing
etransfer_emailfrom the public output, the read endpoints now require the booking capability (matchingAvailabilityEndpoint::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 omitsetransfer_emailfrom the listing (includeEtransferEmail: false); the relevant address is delivered only via the authenticatedcreateIntentresponse for the student's own registration.composer test(200 tests),composer lint,composer csall green.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.