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
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:
@@ -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,
|
||||
|
||||
@@ -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( '/' ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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 ),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user