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]>
This commit is contained in:
@@ -3,17 +3,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Uninstaller;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Site-owner toggles for whether WordPress administrators automatically receive
|
||||
* the studio-admin and/or instructor capabilities.
|
||||
* The site owner's page: whether WordPress administrators automatically receive
|
||||
* the studio-admin and/or instructor capabilities, and what deleting the plugin
|
||||
* takes with it.
|
||||
*
|
||||
* Both default on, preserving the out-of-the-box experience where a single
|
||||
* administrator runs the studio and teaches from one account. The settings page
|
||||
* is gated on `manage_options` (the core WordPress administrator capability,
|
||||
* which the plugin never grants or revokes) so an administrator can always reach
|
||||
* it to re-enable a grant — disabling one can never lock them out.
|
||||
* Both capability grants default on, preserving the out-of-the-box experience
|
||||
* where a single administrator runs the studio and teaches from one account. The
|
||||
* settings page is gated on `manage_options` (the core WordPress administrator
|
||||
* capability, which the plugin never grants or revokes) so an administrator can
|
||||
* always reach it to re-enable a grant — disabling one can never lock them out.
|
||||
*
|
||||
* The data-removal choice lives here for the same reason: `manage_options` is
|
||||
* held by exactly the people who can delete a plugin, so the switch and the act
|
||||
* it governs are in the same pair of hands. {@see Uninstaller} explains what the
|
||||
* two answers mean.
|
||||
*/
|
||||
class AccessSettings {
|
||||
|
||||
@@ -49,21 +56,56 @@ class AccessSettings {
|
||||
wp_die( esc_html__( 'You do not have permission to manage access settings.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_access_action' ) ) {
|
||||
$this->save();
|
||||
$error = $this->save();
|
||||
}
|
||||
|
||||
$adminsAreStudioAdmins = $this->adminsAreStudioAdmins();
|
||||
$adminsAreInstructors = $this->adminsAreInstructors();
|
||||
$deleteDataOnUninstall = Uninstaller::deletesDataOnUninstall();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/access.php';
|
||||
}
|
||||
|
||||
private function save(): void {
|
||||
/**
|
||||
* Persist the submitted settings, reporting why the data-removal choice was
|
||||
* refused when it was. Everything else on the page saves either way: a
|
||||
* mistyped confirmation must not also swallow a capability change.
|
||||
*/
|
||||
private function save(): string {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
update_option( self::OPT_GRANT_STUDIO, isset( $_POST['grant_studio'] ) ? '1' : '0' );
|
||||
update_option( self::OPT_GRANT_INSTRUCTOR, isset( $_POST['grant_instructor'] ) ? '1' : '0' );
|
||||
|
||||
$wanted = isset( $_POST['delete_data'] );
|
||||
|
||||
// Switching it off is not the dangerous direction, and needs no ceremony.
|
||||
if ( ! $wanted ) {
|
||||
Uninstaller::setDeletesDataOnUninstall( false );
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Already on and left on: this save is about something else on the page,
|
||||
// so do not make them retype the word to keep a setting they already made.
|
||||
if ( Uninstaller::deletesDataOnUninstall() ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Turning it on erases records that cannot be got back, so the tick alone
|
||||
// is not enough — it is one stray click, and this is the only place in the
|
||||
// plugin where a stray click is unrecoverable.
|
||||
$confirmed = 'delete' === sanitize_key( Val::string( wp_unslash( $_POST['delete_data_confirm'] ?? '' ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
if ( ! $confirmed ) {
|
||||
return __( 'Data removal was not enabled: type DELETE in the confirmation box to turn it on. Everything else on this page was saved.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
Uninstaller::setDeletesDataOnUninstall( true );
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@ class LoginPage {
|
||||
'remember' => isset( $_POST['rememberme'] ),
|
||||
];
|
||||
|
||||
$user = wp_signon( $credentials, false );
|
||||
// The secure-cookie argument is deliberately left at its default. Only
|
||||
// the empty string makes wp_signon() work it out from is_ssl(); passing
|
||||
// an explicit false skips that and issues the plain, non-Secure auth
|
||||
// cookie on an HTTPS site — a session that then leaks over the first
|
||||
// http:// request to the domain.
|
||||
$user = wp_signon( $credentials );
|
||||
|
||||
if ( is_wp_error( $user ) ) {
|
||||
$error = esc_html__( 'Invalid username or password.', 'unsupervised-schedular' );
|
||||
|
||||
@@ -11,12 +11,59 @@ namespace Unsupervised\Schedular\Auth;
|
||||
*
|
||||
* Both checks key solely off the pending user meta, so invite- and
|
||||
* admin-created students (which carry none of it) are unaffected.
|
||||
*
|
||||
* It also decides which new accounts land in that pending state to begin with —
|
||||
* see {@see holdUnknownSignup()}, which closes the gap left by open registration
|
||||
* turning the site's own `users_can_register` on.
|
||||
*/
|
||||
class RegistrationLoginGate {
|
||||
|
||||
public function register(): void {
|
||||
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
|
||||
add_filter( 'user_has_cap', [ $this, 'withholdBookingWhilePending' ], 10, 4 );
|
||||
add_action( 'user_register', [ $this, 'holdUnknownSignup' ], 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold any student account created by an unauthenticated request that did not
|
||||
* come through the studio's own signup form.
|
||||
*
|
||||
* Enabling open registration switches the site's `users_can_register` on and
|
||||
* makes Student the default role for a new user, because that is what the
|
||||
* studio's registration page needs. But those are *site-wide* settings: they
|
||||
* also arm every other route into `wp_insert_user()` the site happens to have
|
||||
* — another plugin's signup form, a membership add-on — and an account minted
|
||||
* that way arrives holding `book_lesson`, with no email confirmed, no studio
|
||||
* approval, and no policy acceptance on file. It could book and be billed
|
||||
* immediately.
|
||||
*
|
||||
* So the state is decided here, at the one point every path passes through,
|
||||
* rather than trusted to whichever form happened to create the account:
|
||||
*
|
||||
* - **Not a student** — instructors and everyone else are none of this
|
||||
* feature's business.
|
||||
* - **Created by staff** (anyone holding `manage_students`, which includes an
|
||||
* administrator adding a user from wp-admin) — a deliberate act by someone
|
||||
* who could have approved them anyway; approving their own creation is
|
||||
* ceremony, so the account is left active.
|
||||
* - **Anything else** — held, and queued for review under **Pending
|
||||
* Students**.
|
||||
*
|
||||
* The studio's own paths land in the last case and then say what they meant:
|
||||
* a self-signup calls {@see RegistrationStatus::markPending()} (which replaces
|
||||
* the hold with a real, unconfirmed pending state), and an invited student and
|
||||
* a guardian's child are approved outright by the code that creates them.
|
||||
*/
|
||||
public function holdUnknownSignup( int $userId ): void {
|
||||
if ( $userId <= 0 || ! RoleManager::isStudent( $userId ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
RegistrationStatus::hold( $userId );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -431,6 +431,13 @@ class RegistrationPage {
|
||||
}
|
||||
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
// An invited student is pre-approved by the invitation itself — the
|
||||
// studio picked the address and sent the link. Clears the hold the
|
||||
// registration gate puts on every student account created by an
|
||||
// unauthenticated request ({@see RegistrationLoginGate::holdUnknownSignup()}),
|
||||
// which would otherwise leave them signed in but unable to book.
|
||||
RegistrationStatus::approve( (int) $userId );
|
||||
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
|
||||
// A personal invite may carry a group-class grant (invited by email);
|
||||
|
||||
@@ -58,6 +58,11 @@ class RegistrationStatus {
|
||||
$rawToken = wp_generate_password( 32, false );
|
||||
|
||||
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
|
||||
// Explicitly *un*confirmed. The account may already have been held by
|
||||
// {@see hold()} on `user_register` — which counts the email as confirmed,
|
||||
// having never asked for confirmation — and this signup did ask, so the
|
||||
// answer has to be waited for rather than inherited.
|
||||
delete_user_meta( $userId, self::META_EMAIL_CONFIRMED );
|
||||
update_user_meta( $userId, self::META_CONFIRM_TOKEN, self::hashToken( $rawToken ) );
|
||||
update_user_meta(
|
||||
$userId,
|
||||
@@ -72,6 +77,21 @@ class RegistrationStatus {
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a student account that appeared without going through the studio's own
|
||||
* signup form — see {@see RegistrationLoginGate::holdUnknownSignup()}.
|
||||
*
|
||||
* The email counts as confirmed, because nobody ever asked for confirmation
|
||||
* and there is no token to answer with: blocking the login outright would
|
||||
* strand the account with no way forward. What the hold actually withholds is
|
||||
* the booking capability, until a studio admin approves them from **Pending
|
||||
* Students** — the same queue, and the same decision, as a self-signup.
|
||||
*/
|
||||
public static function hold( int $userId ): void {
|
||||
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
|
||||
update_user_meta( $userId, self::META_EMAIL_CONFIRMED, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the account's email confirmed and discard the (now spent) token. The
|
||||
* account stays awaiting approval.
|
||||
|
||||
@@ -330,14 +330,19 @@ class BookingEndpoint {
|
||||
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
||||
$lesson = $this->bookings->findById( $id );
|
||||
|
||||
if ( null === $lesson ) {
|
||||
// A booking that is not the caller's is answered exactly as one that does
|
||||
// not exist. Telling the two apart — 403 here, 404 there — would let any
|
||||
// signed-in student walk the id space and learn which lessons the studio
|
||||
// holds, and roughly how many. There is nothing a student can do with
|
||||
// either answer, so there is no reason to distinguish them.
|
||||
//
|
||||
// The booking form's own 403 (see resolveStudent) is a different case: the
|
||||
// student id there was chosen from a list of people the caller may act for,
|
||||
// so "not yours" is a correction they need, not a fact they lack.
|
||||
if ( null === $lesson || ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) {
|
||||
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
if ( ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
if ( null !== $slot ) {
|
||||
|
||||
@@ -202,14 +202,14 @@ class EnrollmentEndpoint {
|
||||
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
||||
$enrollment = $this->enrollments->findById( $id );
|
||||
|
||||
if ( null === $enrollment ) {
|
||||
// Someone else's enrolment is answered exactly as a nonexistent one, so the
|
||||
// id space cannot be walked to count the studio's enrolments. See
|
||||
// {@see \Unsupervised\Schedular\Booking\BookingEndpoint::cancel()}, which
|
||||
// makes the same trade for the same reason.
|
||||
if ( null === $enrollment || ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) {
|
||||
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
if ( ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
|
||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
@@ -103,6 +104,14 @@ class GuardianService {
|
||||
$userId = (int) $userId;
|
||||
|
||||
update_user_meta( $userId, self::META_CHILD, '1' );
|
||||
|
||||
// A child is a student created by someone who is not staff, so the
|
||||
// registration gate holds it on `user_register` like any other unattributed
|
||||
// signup. There is nothing here to approve: the account is never signed in
|
||||
// to, and the guardian in front of us is the approval. Leaving the hold on
|
||||
// would put every child a family adds into the studio's review queue.
|
||||
RegistrationStatus::approve( $userId );
|
||||
|
||||
$this->setBirthYear( $userId, $birthYear );
|
||||
|
||||
$linkId = $this->guardians->insert(
|
||||
|
||||
@@ -5,6 +5,33 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
class Schema {
|
||||
|
||||
/**
|
||||
* Every table this plugin owns, unprefixed and in creation order.
|
||||
*
|
||||
* The statements in {@see tables()} spell their own names out, so this list is
|
||||
* what anything that needs to *name* the tables without building them reads —
|
||||
* {@see Uninstaller}, which drops them. Add a table below and add it here, or
|
||||
* uninstalling will leave it behind.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const TABLES = [
|
||||
'us_availability',
|
||||
'us_lessons',
|
||||
'us_offerings',
|
||||
'us_questions',
|
||||
'us_question_answers',
|
||||
'us_policies',
|
||||
'us_policy_versions',
|
||||
'us_policy_acceptances',
|
||||
'us_payments',
|
||||
'us_credits',
|
||||
'us_group_enrollments',
|
||||
'us_invites',
|
||||
'us_guardians',
|
||||
'us_group_access',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns CREATE TABLE statements for dbDelta.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,8 +178,13 @@ class UpdateChecker {
|
||||
if ( ! is_array( $asset ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
|
||||
$package = Val::string( $asset['browser_download_url'] ?? '' );
|
||||
if ( ! str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = Val::string( $asset['browser_download_url'] ?? '' );
|
||||
if ( self::isTrustedPackage( $url ) ) {
|
||||
$package = $url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -194,4 +199,40 @@ class UpdateChecker {
|
||||
'package' => $package,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a release asset's URL may be handed to core as a plugin package.
|
||||
*
|
||||
* Core downloads whatever this returns and unpacks it over the installed
|
||||
* plugin, so the URL is executable code by another name. It is taken from a
|
||||
* JSON body, which means an answer that is not really the release server's —
|
||||
* a hijacked hostname, a tampered response, a compromised repo host handing
|
||||
* out a package hosted elsewhere — would otherwise install arbitrary code on
|
||||
* every site running this plugin, silently for anyone with auto-updates on.
|
||||
*
|
||||
* So the package has to come from the release host itself, over TLS: the
|
||||
* scheme is `https` and the parsed host equals {@see HOSTNAME} exactly. The
|
||||
* comparison is on the parsed host and not on the string, because a name that
|
||||
* merely *contains* the right one is the whole trick — `evil-git.unsupervised.ca`
|
||||
* would satisfy an endsWith check, `git.unsupervised.ca.attacker.test` a
|
||||
* startsWith one, and `https://[email protected]/x.zip` reads
|
||||
* like the real host to a person while resolving to someone else's.
|
||||
* Subdomains are refused too: nothing but the release host publishes releases.
|
||||
*
|
||||
* This cannot defend against the release host serving a bad zip of its own —
|
||||
* nothing here can — but it keeps the blast radius to that one host.
|
||||
*/
|
||||
private static function isTrustedPackage( string $url ): bool {
|
||||
if ( '' === $url ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = wp_parse_url( $url );
|
||||
if ( ! is_array( $parts ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'https' === strtolower( Val::string( $parts['scheme'] ?? '' ) )
|
||||
&& self::HOSTNAME === strtolower( Val::string( $parts['host'] ?? '' ) );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user