Security fixes: CSV injection, policy body output, invite hashing, slot datetimes
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / Tests (PHP 8.3) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 59s
CI / Coding Standards (pull_request) Successful in 1m11s
CI / PHPStan (pull_request) Successful in 1m20s
CI / Build Plugin Zip (pull_request) Has been skipped

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]>
This commit is contained in:
2026-06-10 16:36:26 -03:00
co-authored by Claude Fable 5
parent 693246c1c1
commit f3f5c7801f
16 changed files with 162 additions and 25 deletions
+10
View File
@@ -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,
+25 -3
View File
@@ -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( '/' ) );
}
}
+3 -2
View File
@@ -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;