Files
unsupervised-scheduler/src/Uninstaller.php
T
KydoimosandClaude Opus 5 e522789104
CI / Coding Standards (pull_request) Failing after 28s
CI / Tests (PHP 8.5) (pull_request) Failing after 27s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Failing after 39s
CI / Tests (PHP 8.3) (pull_request) Failing after 1m7s
CI / Tests (PHP 8.2) (pull_request) Failing after 1m8s
CI / Static Analysis (pull_request) Successful in 1m17s
CI / Build Plugin Zip (pull_request) Skipped
Fix five findings from a security assessment of the plugin
The assessment looked for three things: whether students can reach each
other's bookings, whether payment settings can be dodged, and whether the
plugin opens a way into the rest of the install. The student-isolation and
payment paths held up. These are what did not.

- The front-end login form told WordPress not to work out whether the site
  was secure, so on HTTPS every student's session cookie was issued without
  the Secure flag. wp_signon() only derives it from is_ssl() when the second
  argument is left at its default; an explicit false reads like "no
  preference" and is not.

- The update check took whatever download URL the release API returned and
  handed it to core, which unpacks it over the installed plugin. The package
  must now be https on git.unsupervised.ca exactly, compared on the parsed
  host so a lookalike name cannot pass.

- Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook
  signing key in wp_options. Removal is now a choice made in advance on
  Access -> Plugin removal: records are kept unless the owner opts in (with a
  typed confirmation), while credentials and the borrowed core registration
  settings go every time.

- Open registration switches on the site-wide users_can_register and makes
  Student the default role, arming any other signup form on the site to mint
  students who could book and be billed immediately. The pending state is now
  decided once, on user_register, rather than by whichever form created the
  account.

- Cancel and withdraw answered "not yours" differently from "does not exist",
  which let a signed-in student enumerate the studio's bookings. Both now
  give the same 404.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:31:12 -03:00

215 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Update\UpdateChecker;
/**
* What deleting the plugin takes with it.
*
* WordPress gives an uninstall no interface of its own — `uninstall.php` runs
* headless, after the plugin is already gone from the screen — so the choice has
* to be made in advance, on **Access → Plugin removal**, and read back here. Two
* things are true at once and the split below is how both are honoured:
*
* - A studio's records are irreplaceable. Lessons taught, payments taken, what
* families agreed to and when: a deactivate-and-reinstall, or a delete during
* a migration, must not be the thing that loses them. So the data is kept
* unless the site owner has explicitly said otherwise.
* - Credentials are not records. The Stripe secret and webhook signing key can
* be re-pasted from the Stripe dashboard in under a minute, and leaving live
* keys in `wp_options` of a site that no longer has the code to use them is
* nothing but exposure. So those go every time, choice or no choice.
*
* The two WordPress settings open registration borrows — `users_can_register`
* and `default_role` — are also always put back. They are the site's, not the
* plugin's, and leaving them behind would leave the site accepting public
* signups into a Student role that may no longer exist.
*/
class Uninstaller {
/**
* Whether deleting the plugin also erases everything it recorded. Off unless
* the site owner turns it on, because the mistake is unrecoverable in one
* direction only.
*/
public const OPT_DELETE_DATA = 'us_delete_data_on_uninstall';
/**
* Stripe credentials and configuration. Always removed — see the class
* docblock.
*
* @var list<string>
*/
private const CREDENTIAL_OPTIONS = [
StudioSettings::OPT_PUBLISHABLE,
StudioSettings::OPT_SECRET,
StudioSettings::OPT_WEBHOOK_SECRET,
StudioSettings::OPT_MODE,
];
/**
* Every remaining option the plugin writes. Removed only on a full purge.
*
* @var list<string>
*/
private const SETTING_OPTIONS = [
'us_schedular_version',
'us_questions_offering_nullable',
'us_questions_child_required_backfilled',
StudioSettings::OPT_CURRENCY,
StudioSettings::OPT_ETRANSFER_EMAIL,
StudioSettings::OPT_HST_RATE,
StudioSettings::OPT_DEFAULT_PAYMENT_METHOD,
StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS,
StudioSettings::OPT_REGISTRATION_MODE,
RegistrationController::OPTION_PAGE,
AccessSettings::OPT_GRANT_STUDIO,
AccessSettings::OPT_GRANT_INSTRUCTOR,
self::OPT_DELETE_DATA,
];
/**
* Every user meta key the plugin writes. Removed for all users on a full
* purge, so no student is left carrying a billing override or a half-finished
* signup for a plugin that is gone.
*
* @var list<string>
*/
private const USER_META = [
GuardianService::META_CHILD,
GuardianService::META_BIRTH_YEAR,
GuardianService::META_DOB,
GuardianService::META_GUARDIAN_ONLY,
BillingMethodResolver::META_METHOD,
RegistrationStatus::META_AWAITING_APPROVAL,
RegistrationStatus::META_EMAIL_CONFIRMED,
RegistrationStatus::META_CONFIRM_TOKEN,
RegistrationStatus::META_CONFIRM_EXPIRES,
RegistrationStatus::META_AUTO_APPROVE,
];
/**
* Whether a full purge has been asked for.
*/
public static function deletesDataOnUninstall(): bool {
return '1' === Val::string( get_option( self::OPT_DELETE_DATA, '0' ) );
}
/**
* Record the site owner's answer.
*/
public static function setDeletesDataOnUninstall( bool $delete ): void {
update_option( self::OPT_DELETE_DATA, $delete ? '1' : '0' );
}
/**
* Run the uninstall. Called from `uninstall.php`, which WordPress loads in
* isolation once the plugin has been deleted.
*/
public function run(): void {
$this->forgetCredentials();
$this->restoreCoreRegistrationSettings();
// The event is cleared on deactivation too, which always precedes a
// delete — repeated here because a site can be left with a stale schedule
// if the plugin files went away without deactivating cleanly.
wp_clear_scheduled_hook( ScheduledBillingRunner::HOOK );
if ( ! self::deletesDataOnUninstall() ) {
return;
}
$this->dropTables();
$this->deleteSettings();
$this->deleteUserMeta();
$this->removeRoles();
}
/**
* Forget the Stripe credentials, always. {@see StudioSettings::clearStripeConfig()}
* is the same act from the settings page; it is not reused here because that
* object belongs to a plugin that, by this point, is no longer loaded as one.
*/
private function forgetCredentials(): void {
foreach ( self::CREDENTIAL_OPTIONS as $option ) {
delete_option( $option );
}
delete_transient( UpdateChecker::TRANSIENT );
}
/**
* Put back the two core options open registration borrowed, from the snapshot
* taken when it was switched on. Without this, deleting the plugin while open
* registration is enabled leaves the site accepting public signups into a role
* that is about to stop existing.
*
* Only acts when a snapshot exists, so a site that never enabled open
* registration keeps its own settings untouched.
*/
private function restoreCoreRegistrationSettings(): void {
$snapshot = get_option( StudioSettings::OPT_PREV_USERS_CAN_REGISTER, null );
if ( null === $snapshot ) {
return;
}
$prevRole = Val::string( get_option( StudioSettings::OPT_PREV_DEFAULT_ROLE, 'subscriber' ) );
update_option( 'users_can_register', '1' === Val::string( $snapshot ) ? '1' : '0' );
update_option( 'default_role', '' !== $prevRole ? $prevRole : 'subscriber' );
delete_option( StudioSettings::OPT_PREV_USERS_CAN_REGISTER );
delete_option( StudioSettings::OPT_PREV_DEFAULT_ROLE );
}
private function dropTables(): void {
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
foreach ( Schema::TABLES as $table ) {
$sql = $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table );
if ( null !== $sql ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- uninstall drops the plugin's own tables; the names come from Schema::TABLES, not from input.
$wpdb->query( $sql );
}
}
}
private function deleteSettings(): void {
foreach ( self::SETTING_OPTIONS as $option ) {
delete_option( $option );
}
}
private function deleteUserMeta(): void {
foreach ( self::USER_META as $key ) {
delete_metadata( 'user', 0, $key, '', true );
}
}
/**
* Drop the three roles the plugin adds. Only on a full purge: a site keeping
* its data is keeping its students too, and a student whose role has been
* deleted out from under them is a user with no capabilities at all until the
* plugin is reinstalled.
*/
private function removeRoles(): void {
foreach ( [ RoleManager::STUDIO_ADMIN, RoleManager::INSTRUCTOR, RoleManager::STUDENT ] as $role ) {
remove_role( $role );
}
}
}