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;
+3 -3
View File
@@ -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;
}
+9 -2
View File
@@ -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,
);
+19
View File
@@ -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,
+11 -2
View File
@@ -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<string> $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
);
+4 -1
View File
@@ -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 ),
];
}