Files
unsupervised-scheduler/src/Auth/AccessSettings.php
T
KydoimosandClaude Opus 5 1847159e31 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:55:53 -03:00

112 lines
4.0 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Uninstaller;
use Unsupervised\Schedular\Val;
/**
* 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 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 {
public const OPT_GRANT_STUDIO = 'us_admin_grant_studio';
public const OPT_GRANT_INSTRUCTOR = 'us_admin_grant_instructor';
/**
* Whether WordPress administrators implicitly hold the studio-admin
* capabilities (offerings, policies, billing, reports, …).
*/
public function adminsAreStudioAdmins(): bool {
return $this->flag( self::OPT_GRANT_STUDIO );
}
/**
* Whether WordPress administrators implicitly hold the instructor
* capabilities (manage their own availability and lessons).
*/
public function adminsAreInstructors(): bool {
return $this->flag( self::OPT_GRANT_INSTRUCTOR );
}
/**
* Read a stored toggle, defaulting to on so a fresh install keeps the
* single-account behaviour.
*/
private function flag( string $option ): bool {
return '0' !== Val::string( get_option( $option, '1' ) );
}
public function renderPage(): void {
if ( ! current_user_can( 'manage_options' ) ) {
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' ) ) {
$error = $this->save();
}
$adminsAreStudioAdmins = $this->adminsAreStudioAdmins();
$adminsAreInstructors = $this->adminsAreInstructors();
$deleteDataOnUninstall = Uninstaller::deletesDataOnUninstall();
include USC_PLUGIN_DIR . 'templates/admin/access.php';
}
/**
* 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 '';
}
}