403 ] );
}
}
/**
* @param array $links
* @param string $file
* @return array
*/
public function lock_action_links( $links, $file ) {
if ( self::is_ours( $file ) && self::locked_for_current_user() ) {
unset( $links['deactivate'], $links['delete'] );
}
return $links;
}
/**
* The bulk "Deactivate" action and a few REST paths call deactivate_plugins()
* with no per-plugin capability check, so the option write is the one place
* every route has to pass through.
*
* @param mixed $new
* @param mixed $old
* @return mixed
*/
public function keep_active( $new, $old ) {
if ( ! is_user_logged_in() || ! self::locked_for_current_user() || ! is_array( $new ) || ! is_array( $old ) ) {
return $new;
}
$dropped = [];
foreach ( $old as $file ) {
if ( self::is_ours( $file ) && ! in_array( $file, $new, true ) ) {
$dropped[] = $file;
}
}
if ( $dropped ) {
Logger::log(
'plugin_lock_blocked',
__( 'Blocked an attempt to deactivate GemSecurity (plugin lock).', 'gemsecurity' ),
Logger::SEVERITY_WARNING
);
$new = array_values( array_unique( array_merge( $new, $dropped ) ) );
}
return $new;
}
/**
* @param mixed $new
* @param mixed $old
* @return mixed
*/
public function keep_active_network( $new, $old ) {
if ( ! is_user_logged_in() || ! self::locked_for_current_user() || ! is_array( $new ) || ! is_array( $old ) ) {
return $new;
}
foreach ( $old as $file => $time ) {
if ( self::is_ours( $file ) && ! isset( $new[ $file ] ) ) {
$new[ $file ] = $time;
}
}
return $new;
}
/**
* @param array $caps
* @return array
*/
public function strip_code_caps( $caps ) {
if ( ! self::locked_for_current_user() ) {
return $caps;
}
foreach ( self::CODE_CAPS as $cap ) {
unset( $caps[ $cap ] );
}
return $caps;
}
/**
* Keep protected settings as they are while the lock is closed.
*
* @param array $current Stored settings.
* @param array $merged Settings about to be stored.
* @return array{0:array,1:bool} The settings to store, and whether anything was held back.
*/
public static function guard_protected( array $current, array $merged ) {
if ( ! self::locked_for_current_user() ) {
return [ $merged, false ];
}
$held = false;
foreach ( self::PROTECTED_KEYS as $key ) {
$old = array_key_exists( $key, $current ) ? $current[ $key ] : null;
$new = array_key_exists( $key, $merged ) ? $merged[ $key ] : null;
if ( wp_json_encode( $old ) !== wp_json_encode( $new ) ) {
$merged[ $key ] = $old;
$held = true;
}
}
return [ $merged, $held ];
}
/**
* The lock switch is not an ordinary setting: the settings form and import
* can neither turn it on (no password would exist) nor off (no password
* would be asked). Only the lock endpoints below change it.
*
* @param array $current Stored settings.
* @param array $merged Settings about to be stored.
* @return array
*/
public static function guard_lock_settings( array $current, array $merged ) {
$merged['plugin_lock_enabled'] = ! empty( $current['plugin_lock_enabled'] );
return $merged;
}
/* --------------------------------------------------------------------- */
/* Lock endpoints */
/* --------------------------------------------------------------------- */
/** GET /lock */
public static function lock_status() {
return rest_ensure_response( [
'success' => true,
'enabled' => self::lock_on(),
'unlocked' => self::is_unlocked(),
'disabled_by_constant' => defined( 'GEMSEC_PLUGIN_LOCK_DISABLE' ),
] );
}
/** POST /lock/enable { password } — only while no lock exists. */
public static function lock_enable( $req ) {
if ( self::lock_on() ) {
return new \WP_Error( 'gemsecurity_lock', __( 'The lock is already on.', 'gemsecurity' ), [ 'status' => 409 ] );
}
$email = (string) wp_get_current_user()->user_email;
if ( ! is_email( $email ) ) {
return new \WP_Error( 'gemsecurity_lock', __( 'Your account has no valid email address. Unlock codes are sent by email, so add one to your profile first.', 'gemsecurity' ), [ 'status' => 400 ] );
}
$password = (string) $req->get_param( 'password' );
if ( strlen( $password ) < 8 ) {
return new \WP_Error( 'gemsecurity_lock', __( 'Use at least 8 characters for the unlock password.', 'gemsecurity' ), [ 'status' => 400 ] );
}
self::$writing = true;
update_option( self::LOCK_HASH, [ 'h' => wp_hash_password( $password ), 'email' => $email ], false );
self::$writing = false;
self::store_flag( true );
// The person who just set it stays unlocked for this session.
Store::set( self::unlock_key(), 1, self::UNLOCK_TTL );
Logger::log( 'plugin_lock_enabled', __( 'GemSecurity plugin lock turned on.', 'gemsecurity' ), Logger::SEVERITY_NOTICE );
return self::lock_status();
}
/**
* POST /lock/unlock { password } — step 1 of 2.
*
* A correct password does not open the lock; it emails a one-time code.
* The code (step 2, lock_verify) opens it.
*/
public static function lock_unlock( $req ) {
$hash = self::hash();
$email = self::lock_email();
if ( '' === $hash ) {
return new \WP_Error( 'gemsecurity_lock', __( 'No lock is set.', 'gemsecurity' ), [ 'status' => 409 ] );
}
if ( ! is_email( $email ) ) {
return new \WP_Error( 'gemsecurity_lock', __( 'This lock has no email address on file. Remove it with the wp-config recovery line and set it up again.', 'gemsecurity' ), [ 'status' => 409 ] );
}
$uid = get_current_user_id();
$tries_key = 'gemsec_lock_tries_' . $uid;
$tries = (int) Store::get( $tries_key );
if ( $tries >= self::MAX_TRIES ) {
return new \WP_Error( 'gemsecurity_lock', __( 'Too many wrong passwords. Try again in 15 minutes.', 'gemsecurity' ), [ 'status' => 429 ] );
}
if ( ! wp_check_password( (string) $req->get_param( 'password' ), $hash ) ) {
Store::set( $tries_key, $tries + 1, 15 * MINUTE_IN_SECONDS );
Logger::log( 'plugin_lock_failed', __( 'Wrong GemSecurity unlock password entered.', 'gemsecurity' ), Logger::SEVERITY_WARNING );
return new \WP_Error( 'gemsecurity_lock', __( 'Wrong password.', 'gemsecurity' ), [ 'status' => 403 ] );
}
Store::delete( $tries_key );
// Cap how many codes can be requested, so this cannot be used to flood the inbox.
$sends_key = 'gemsec_lock_sends_' . $uid;
$sends = (int) Store::get( $sends_key );
if ( $sends >= self::MAX_SENDS ) {
return new \WP_Error( 'gemsecurity_lock', __( 'Too many codes requested. Try again in 15 minutes.', 'gemsecurity' ), [ 'status' => 429 ] );
}
$code = (string) random_int( 100000, 999999 );
$sent = Mailer::send(
$email,
'🔐',
__( 'Your GemSecurity unlock code', 'gemsecurity' ),
[
[ 'type' => 'paragraph', 'text' => esc_html__( 'Someone entered the unlock password to open the GemSecurity plugin lock. Use this code to finish:', 'gemsecurity' ) ],
[ 'type' => 'stats', 'items' => [ __( 'Unlock code', 'gemsecurity' ) => $code ] ],
[ 'type' => 'notice', 'tone' => 'warning', 'text' => esc_html__( 'The code works for 10 minutes. If this was not you, someone knows your unlock password. Do not share the code.', 'gemsecurity' ) ],
]
);
if ( ! $sent ) {
return new \WP_Error( 'gemsecurity_lock', __( 'The email with the code could not be sent. Check that your site can send email.', 'gemsecurity' ), [ 'status' => 500 ] );
}
Store::set( $sends_key, $sends + 1, 15 * MINUTE_IN_SECONDS );
Store::set( self::code_key(), [ 'c' => wp_hash( $code ), 'tries' => 0 ], self::CODE_TTL );
Logger::log( 'plugin_lock_code_sent', __( 'GemSecurity unlock code emailed.', 'gemsecurity' ), Logger::SEVERITY_NOTICE );
return rest_ensure_response( [ 'success' => true, 'step' => 'code', 'email' => self::mask_email( $email ) ] );
}
/** POST /lock/verify { code } — step 2 of 2: the emailed code opens the lock. */
public static function lock_verify( $req ) {
$pending = Store::get( self::code_key() );
if ( ! is_array( $pending ) ) {
return new \WP_Error( 'gemsecurity_lock', __( 'No code is waiting, or it expired. Enter the password again to get a new one.', 'gemsecurity' ), [ 'status' => 409 ] );
}
if ( (int) $pending['tries'] >= self::MAX_CODE_TRIES ) {
Store::delete( self::code_key() );
return new \WP_Error( 'gemsecurity_lock', __( 'Too many wrong codes. Enter the password again to get a new one.', 'gemsecurity' ), [ 'status' => 429 ] );
}
$code = preg_replace( '/\D/', '', (string) $req->get_param( 'code' ) );
if ( '' === $code || ! hash_equals( (string) $pending['c'], wp_hash( $code ) ) ) {
$pending['tries'] = (int) $pending['tries'] + 1;
Store::set( self::code_key(), $pending, self::CODE_TTL );
Logger::log( 'plugin_lock_failed', __( 'Wrong GemSecurity unlock code entered.', 'gemsecurity' ), Logger::SEVERITY_WARNING );
return new \WP_Error( 'gemsecurity_lock', __( 'Wrong code.', 'gemsecurity' ), [ 'status' => 403 ] );
}
Store::delete( self::code_key() );
Store::set( self::unlock_key(), 1, self::UNLOCK_TTL );
Logger::log( 'plugin_lock_opened', __( 'GemSecurity plugin lock opened for 10 minutes.', 'gemsecurity' ), Logger::SEVERITY_NOTICE );
return self::lock_status();
}
/** @return string Transient key for the pending emailed code (user + login session). */
private static function code_key() {
return 'gemsec_lock_code_' . md5( get_current_user_id() . '|' . wp_get_session_token() );
}
/** @return string j***@example.com */
private static function mask_email( $email ) {
list( $name, $domain ) = array_pad( explode( '@', $email, 2 ), 2, '' );
return substr( $name, 0, 1 ) . str_repeat( '*', max( 2, strlen( $name ) - 1 ) ) . '@' . $domain;
}
/** POST /lock/relock — close it again right away. */
public static function lock_relock() {
Store::delete( self::unlock_key() );
return self::lock_status();
}
/** POST /lock/disable — remove the lock (needs it to be open first). */
public static function lock_disable() {
if ( self::locked_for_current_user() ) {
return new \WP_Error( 'gemsecurity_lock', __( 'Enter the unlock password first.', 'gemsecurity' ), [ 'status' => 403 ] );
}
self::$writing = true;
delete_option( self::LOCK_HASH );
self::$writing = false;
self::store_flag( false );
Logger::log( 'plugin_lock_disabled', __( 'GemSecurity plugin lock turned off.', 'gemsecurity' ), Logger::SEVERITY_WARNING );
return self::lock_status();
}
/** Write the on/off flag straight to the option (the settings path refuses to). */
private static function store_flag( $on ) {
$settings = get_option( GEMSECURITY_SETTINGS_NAME, [] );
$settings = is_array( $settings ) ? $settings : [];
$settings['plugin_lock_enabled'] = (bool) $on;
update_option( GEMSECURITY_SETTINGS_NAME, $settings );
}
/* --------------------------------------------------------------------- */
/* File uploads */
/* --------------------------------------------------------------------- */
/**
* @param array $file
* @return array
*/
public function refuse_upload( $file ) {
$file['error'] = __( 'File uploads are turned off on this site.', 'gemsecurity' );
return $file;
}
/**
* @return bool False makes WordPress report the upload as failed.
*/
public function refuse_move() {
return false;
}
/**
* @param array $caps
* @return array
*/
public function strip_upload_caps( $caps ) {
unset( $caps['upload_files'], $caps['unfiltered_upload'] );
return $caps;
}
/**
* Admin-bar switch for the uploads lock.
*
* @param \WP_Admin_Bar $bar
*/
public function admin_bar( $bar ) {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$off = (bool) Helper::get_setting( 'uploads_disabled', false );
// Show badge counts of hardening switches that are currently ON, so
// an administrator can tell at a glance that the site is locked
// down (and jump to the setting to turn one off).
$active = $this->active_hardening_chips();
$score = $this->cached_security_score();
$title = ''
. '' . esc_html__( 'GemSecurity', 'gemsecurity' ) . '';
// Score pill: colored by grade so it reads at a glance (green=A/B,
// amber=C/D, red=F). Clicking the parent still opens the dashboard,
// where the "boost your score" breakdown explains what to fix.
if ( null !== $score ) {
$tone = $score['score'] >= 75 ? 'ok' : ( $score['score'] >= 40 ? 'warn' : 'bad' );
$title .= '' . (int) $score['score'] . '';
}
if ( $active ) {
$title .= '✓ ' . (int) count( $active ) . '';
}
$bar->add_node( [
'id' => 'gemsecurity',
'title' => $title,
'href' => admin_url( 'admin.php?page=' . GEMSECURITY_PLUGIN_SLUG ),
] );
// Score header inside the dropdown — repeats the badge with its grade
// and a one-line nudge, so a low score is visible even before the
// hardening chips. Links to the dashboard where the fix list lives.
$dashboard = admin_url( 'admin.php?page=' . GEMSECURITY_PLUGIN_SLUG );
if ( null !== $score ) {
$tone = $score['score'] >= 75 ? 'ok' : ( $score['score'] >= 40 ? 'warn' : 'bad' );
$bar->add_node( [
'id' => 'gemsecurity-score',
'parent' => 'gemsecurity',
'title' => ''
. '' . esc_html__( 'Security score', 'gemsecurity' ) . ''
. ''
. (int) $score['score'] . '/100 · ' . esc_html( $score['grade'] ) . ''
. '' . esc_html( $this->score_nudge( $score['score'] ) ) . ''
. '',
'href' => $dashboard,
'meta' => [ 'class' => 'gemsecurity-ab-scoreitem' ],
] );
}
// One child per hardening switch currently ON — a quick status
// readout that doubles as a jump list to each setting's page.
if ( $active ) {
$bar->add_node( [
'id' => 'gemsecurity-h-protect',
'parent' => 'gemsecurity',
'title' => esc_html__( 'Active protections', 'gemsecurity' ),
'meta' => [ 'class' => 'gemsecurity-ab-heading' ],
] );
foreach ( $active as $chip ) {
$bar->add_node( [
'id' => 'gemsecurity-chip-' . $chip['id'],
'parent' => 'gemsecurity',
'title' => '✓' . esc_html( $chip['label'] ),
'href' => esc_url( $chip['href'] ),
] );
}
}
$bar->add_node( [
'id' => 'gemsecurity-h-actions',
'parent' => 'gemsecurity',
'title' => esc_html__( 'Quick actions', 'gemsecurity' ),
'meta' => [ 'class' => 'gemsecurity-ab-heading' ],
] );
// Uploads toggle stays its own node: it is the one chip that
// toggles in-place from the admin bar rather than linking out.
$bar->add_node( [
'id' => 'gemsecurity-uploads',
'parent' => 'gemsecurity',
'title' => ''
. ( $off
? esc_html__( 'File uploads: blocked', 'gemsecurity' )
: esc_html__( 'File uploads: allowed', 'gemsecurity' ) )
. ''
. ( $off ? esc_html__( 'Allow', 'gemsecurity' ) : esc_html__( 'Block', 'gemsecurity' ) )
. '',
'href' => wp_nonce_url( admin_url( 'admin-post.php?action=' . self::TOGGLE_ACTION ), self::TOGGLE_ACTION ),
] );
$bar->add_node( [
'id' => 'gemsecurity-open',
'parent' => 'gemsecurity',
'title' => esc_html__( 'Open GemSecurity dashboard', 'gemsecurity' ) . ' →',
'href' => $dashboard,
] );
}
/**
* The high-impact hardening switches that are currently ON, as admin-bar
* chips: [{ id, label, href }, …]. Only switches that materially change
* site behaviour are listed here — a long row of "everything's fine" is
* noise, so each entry is something an admin should know is active.
*
* @return array
*/
private function active_hardening_chips() {
$firewall = admin_url( 'admin.php?page=' . GEMSECURITY_PLUGIN_SLUG . '-firewall' );
$settings = admin_url( 'admin.php?page=' . GEMSECURITY_PLUGIN_SLUG . '-settings' );
$wp_general = admin_url( 'options-general.php' );
$candidates = [
[
'id' => 'file-editor',
'on' => (bool) Helper::get_setting( 'disable_file_editing', false ),
'label' => __( 'File editor off', 'gemsecurity' ),
'href' => $firewall,
],
[
'id' => 'xmlrpc',
'on' => (bool) Helper::get_setting( 'xmlrpc_disabled', false ),
'label' => __( 'XML-RPC off', 'gemsecurity' ),
'href' => $firewall,
],
[
'id' => 'plugin-lock',
'on' => (bool) Helper::get_setting( 'plugin_lock_enabled', false ),
'label' => __( 'Plugin lock on', 'gemsecurity' ),
'href' => $settings,
],
[
'id' => 'registration',
'on' => ! (bool) get_option( 'users_can_register' ),
'label' => __( 'User registration off', 'gemsecurity' ),
'href' => $wp_general,
],
];
return array_values( array_filter( $candidates, static function ( $c ) {
return ! empty( $c['on'] );
} ) );
}
/**
* Memoised security score for the admin bar. Full assessment runs posture
* checks and threat scans, which would be wasted on every page load, so a
* five-minute transient keeps the cost bounded. Returns null if the
* SecurityScore class is unreachable (e.g. during an upgrade).
*
* @return array{score:int,grade:string}|null
*/
private function cached_security_score() {
if ( ! class_exists( SecurityScore::class ) ) {
return null;
}
$cached = get_transient( 'gemsecurity_score_ab' );
if ( is_array( $cached ) && isset( $cached['score'], $cached['grade'] ) ) {
return $cached;
}
$a = SecurityScore::assess();
$out = [ 'score' => (int) $a['score'], 'grade' => (string) $a['grade'] ];
set_transient( 'gemsecurity_score_ab', $out, 5 * MINUTE_IN_SECONDS );
return $out;
}
/**
* One-line nudge for the admin-bar score header, tuned to the grade range
* so the message isn't "fix this" at 95 or "you're set" at 20.
*
* @param int $score
* @return string
*/
private function score_nudge( $score ) {
if ( $score >= 90 ) {
return __( 'Looking great.', 'gemsecurity' );
}
if ( $score >= 75 ) {
return __( 'A few switches away from an A.', 'gemsecurity' );
}
if ( $score >= 60 ) {
return __( 'Review your to-do list.', 'gemsecurity' );
}
if ( $score >= 40 ) {
return __( 'At risk — fix the warnings.', 'gemsecurity' );
}
return __( 'Open the dashboard and start with the critical items.', 'gemsecurity' );
}
/**
* Inline CSS for the admin-bar count pill, attached to core's admin-bar
* stylesheet. Only added when the admin bar is actually showing.
*/
public function admin_bar_css() {
if ( ! is_admin_bar_showing() || ! current_user_can( 'manage_options' ) ) {
return;
}
$css = <<<'CSS'
#wpadminbar #wp-admin-bar-gemsecurity > .ab-item { display: flex; align-items: center; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-count,
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-score {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; padding: 0 6px; margin-left: 6px;
color: #fff;
border-radius: 9px;
font-size: 11px; font-weight: 600; line-height: 1;
}
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-count { background: #00AD6B; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-score--ok { background: #00AD6B; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-score--warn { background: #FDB022; color: #3B2706; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-score--bad { background: #E2483D; }
#wpadminbar #wp-admin-bar-gemsecurity:hover .gemsecurity-ab-count,
#wpadminbar #wp-admin-bar-gemsecurity.hover .gemsecurity-ab-count,
#wpadminbar #wp-admin-bar-gemsecurity:hover .gemsecurity-ab-score--ok,
#wpadminbar #wp-admin-bar-gemsecurity.hover .gemsecurity-ab-score--ok,
#wpadminbar #wp-admin-bar-gemsecurity:hover .gemsecurity-ab-score--bad,
#wpadminbar #wp-admin-bar-gemsecurity.hover .gemsecurity-ab-score--bad { color: #fff; }
/* Dropdown: roomier rows, a clear score header and labelled sections. */
#wpadminbar #wp-admin-bar-gemsecurity .ab-sub-wrapper { min-width: 260px; }
#wpadminbar #wp-admin-bar-gemsecurity .ab-submenu { padding: 6px 0; }
#wpadminbar #wp-admin-bar-gemsecurity .ab-submenu .ab-item { display: flex; align-items: center; gap: 8px; height: auto; min-height: 30px; line-height: 1.4; padding: 4px 14px; white-space: normal; }
#wpadminbar #wp-admin-bar-gemsecurity .ab-submenu .gemsecurity-ab-score { margin-left: 0; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-scoreitem > .ab-item { padding: 8px 14px 10px; border-bottom: 1px solid rgba(255,255,255,.12); margin-bottom: 4px; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-scorehead { display: flex; flex-direction: column; gap: 4px; width: 100%; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-scorehead__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-scorehead__note { font-size: 12px; opacity: .75; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-heading > .ab-item { min-height: 0; padding: 8px 14px 2px; font-size: 10.5px; font-weight: 600; letter-spacing: .6px; text-transform: uppercase; color: #8c8f94 !important; cursor: default; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-check { color: #34d399; font-weight: 700; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-dot--on { background: #34d399; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-dot--off { background: #f87171; }
#wpadminbar #wp-admin-bar-gemsecurity .gemsecurity-ab-action { margin-left: auto; padding: 1px 8px; border-radius: 10px; background: rgba(255,255,255,.14); font-size: 11px; font-weight: 600; }
#wpadminbar #wp-admin-bar-gemsecurity-open > .ab-item { margin-top: 4px; border-top: 1px solid rgba(255,255,255,.12); color: #72aee6 !important; }
CSS;
wp_enqueue_style( 'admin-bar' );
wp_add_inline_style( 'admin-bar', $css );
}
public function handle_toggle() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'gemsecurity' ), '', [ 'response' => 403 ] );
}
check_admin_referer( self::TOGGLE_ACTION );
if ( self::locked_for_current_user() ) {
wp_die(
esc_html__( 'GemSecurity is locked. Unlock it on the Firewall page (password and emailed code) before changing this.', 'gemsecurity' ),
'',
[ 'response' => 403, 'back_link' => true ]
);
}
Settings::apply( [ 'uploads_disabled' => ! Helper::get_setting( 'uploads_disabled', false ) ], 'instant' );
$back = wp_get_referer();
wp_safe_redirect( $back ? $back : admin_url() );
exit;
}
}