Fixes the four actionable findings from the security review pass: #39, #40, #41, #42.
What changed
CSV formula injection in the payments export (#39 — Medium)
PaymentReport::csvLine() now apostrophe-prefixes any field with a leading =, +, -, @, tab, or CR before quoting. Student display names are self-chosen at registration and flow into the CSV the studio admin opens in Excel/Google Sheets — previously a name like =HYPERLINK(...) became a live formula. New test covers hostile names across all trigger characters.
Policy bodies sanitised at output (#40 — defense-in-depth)
PolicyEndpoint::index() runs the body through wp_kses_post() before returning it. The booking/group-class JS renders this HTML raw via innerHTML; protection previously rested entirely on every write path remembering to kses. Now a missed write path can't become stored XSS against students.
Only hash('sha256', $token) is stored in us_invites; the registration flow hashes the submitted token for lookup (Invite::hashToken()). The admin Invites page shows the full registration link once, in a notice right after creation — the pending list now shows email + invited date, and a lost link is re-issued by revoke + re-invite. A database leak (backup, SQLi in an unrelated plugin) can no longer be used to redeem pending invites and mint accounts.
Note: existing plaintext pending invites stop matching after this change and must be revoked and re-issued (pre-1.0, no migration).
New AvailabilitySlot::normalizeDateTime() accepts canonical Y-m-d H:i[:s] and HTML datetime-local (Y-m-d\TH:i[:s]) forms, canonicalises to Y-m-d H:i:s, and rejects everything else via a strict round-trip check (no PHP date rollover: 2026-02-30 is refused). Both creation paths use it — REST returns 400 invalid_datetime, the admin form no-ops like the existing empty-field check — and both now also require end > start. Previously garbage reached the DATETIME column on the single path and threw an unhandled exception inside the weekly-series date arithmetic.
Checks
composer test (204 tests, 594 assertions), PHPStan L6, and PHPCS all green. Feature docs updated (account-registration, payment-reporting, availability-management).
Fixes the four actionable findings from the security review pass: #39, #40, #41, #42.
## What changed
### CSV formula injection in the payments export (#39 — Medium)
`PaymentReport::csvLine()` now apostrophe-prefixes any field with a leading `=`, `+`, `-`, `@`, tab, or CR before quoting. Student display names are self-chosen at registration and flow into the CSV the studio admin opens in Excel/Google Sheets — previously a name like `=HYPERLINK(...)` became a live formula. New test covers hostile names across all trigger characters.
### Policy bodies sanitised at output (#40 — defense-in-depth)
`PolicyEndpoint::index()` runs the body through `wp_kses_post()` before returning it. The booking/group-class JS renders this HTML raw via `innerHTML`; protection previously rested entirely on every write path remembering to kses. Now a missed write path can't become stored XSS against students.
### Invite tokens hashed at rest (#41)
Only `hash('sha256', $token)` is stored in `us_invites`; the registration flow hashes the submitted token for lookup (`Invite::hashToken()`). The admin Invites page shows the full registration link **once**, in a notice right after creation — the pending list now shows email + invited date, and a lost link is re-issued by revoke + re-invite. A database leak (backup, SQLi in an unrelated plugin) can no longer be used to redeem pending invites and mint accounts.
**Note:** existing plaintext pending invites stop matching after this change and must be revoked and re-issued (pre-1.0, no migration).
### Availability datetime validation (#42)
New `AvailabilitySlot::normalizeDateTime()` accepts canonical `Y-m-d H:i[:s]` and HTML `datetime-local` (`Y-m-d\TH:i[:s]`) forms, canonicalises to `Y-m-d H:i:s`, and rejects everything else via a strict round-trip check (no PHP date rollover: `2026-02-30` is refused). Both creation paths use it — REST returns `400 invalid_datetime`, the admin form no-ops like the existing empty-field check — and both now also require `end > start`. Previously garbage reached the DATETIME column on the single path and threw an unhandled exception inside the weekly-series date arithmetic.
## Checks
`composer test` (204 tests, 594 assertions), PHPStan L6, and PHPCS all green. Feature docs updated (account-registration, payment-reporting, availability-management).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Four fixes from a security review pass:
- Neutralise CSV formula injection in the payments export: fields with a
leading =, +, -, @, tab, or CR (e.g. a hostile student display name) are
apostrophe-prefixed in PaymentReport::csvLine() so they open as text in
Excel/Google Sheets. Fixes#39.
- Sanitise policy bodies with wp_kses_post at output in
PolicyEndpoint::index() (the booking JS renders that HTML raw), so a
future write path that forgets kses can never become stored XSS.
Fixes#40.
- Store invite tokens hashed (SHA-256) at rest: a database leak can no
longer redeem pending invites. The registration link is shown once, at
creation; the pending list shows email/invited date; lookups hash the
submitted token. Existing plaintext pending invites must be re-issued.
Fixes#41.
- Validate availability slot datetimes on both creation paths (REST and
admin form) via AvailabilitySlot::normalizeDateTime(): canonical and
datetime-local forms normalise to Y-m-d H:i:s, garbage and end <= start
are rejected (REST 400) instead of reaching the DATETIME column or
throwing inside the weekly-series date arithmetic. Fixes#42.
composer test (204 tests, 594 assertions), PHPStan L6, and PHPCS all green.
Co-Authored-By: Claude Fable 5 <[email protected]>
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.
Fixes the four actionable findings from the security review pass: #39, #40, #41, #42.
What changed
CSV formula injection in the payments export (#39 — Medium)
PaymentReport::csvLine()now apostrophe-prefixes any field with a leading=,+,-,@, tab, or CR before quoting. Student display names are self-chosen at registration and flow into the CSV the studio admin opens in Excel/Google Sheets — previously a name like=HYPERLINK(...)became a live formula. New test covers hostile names across all trigger characters.Policy bodies sanitised at output (#40 — defense-in-depth)
PolicyEndpoint::index()runs the body throughwp_kses_post()before returning it. The booking/group-class JS renders this HTML raw viainnerHTML; protection previously rested entirely on every write path remembering to kses. Now a missed write path can't become stored XSS against students.Invite tokens hashed at rest (#41)
Only
hash('sha256', $token)is stored inus_invites; the registration flow hashes the submitted token for lookup (Invite::hashToken()). The admin Invites page shows the full registration link once, in a notice right after creation — the pending list now shows email + invited date, and a lost link is re-issued by revoke + re-invite. A database leak (backup, SQLi in an unrelated plugin) can no longer be used to redeem pending invites and mint accounts.Note: existing plaintext pending invites stop matching after this change and must be revoked and re-issued (pre-1.0, no migration).
Availability datetime validation (#42)
New
AvailabilitySlot::normalizeDateTime()accepts canonicalY-m-d H:i[:s]and HTMLdatetime-local(Y-m-d\TH:i[:s]) forms, canonicalises toY-m-d H:i:s, and rejects everything else via a strict round-trip check (no PHP date rollover:2026-02-30is refused). Both creation paths use it — REST returns400 invalid_datetime, the admin form no-ops like the existing empty-field check — and both now also requireend > start. Previously garbage reached the DATETIME column on the single path and threw an unhandled exception inside the weekly-series date arithmetic.Checks
composer test(204 tests, 594 assertions), PHPStan L6, and PHPCS all green. Feature docs updated (account-registration, payment-reporting, availability-management).🤖 Generated with Claude Code