log() ); if ( $blocked > 0 ) { return sprintf( /* translators: %s: number of blocked attempts. */ _n( '%s blocked', '%s blocked', $blocked, 'adminkeep' ), number_format_i18n( $blocked ) ); } return __( 'Closed', 'adminkeep' ); } /** * Settings schema. No translated strings — this runs before init. * * @return array */ public function settings_schema() { return array( 'guard_admin' => array( 'type' => 'bool', 'default' => true, ), 'block_programmatic' => array( 'type' => 'bool', 'default' => false, ), self::LOG_KEY => array( 'type' => 'array', 'default' => array(), 'ui' => 'status', ), ); } /** * Field labels. Render-time only. * * @param string $key Field key. * @return string */ public function field_label( $key ) { $labels = array( 'guard_admin' => __( 'Refuse creation of, or promotion to, administrator', 'adminkeep' ), 'block_programmatic' => __( 'Also block accounts created by plugin code (breaks WooCommerce checkout and membership plugins)', 'adminkeep' ), self::LOG_KEY => __( 'Blocked administrator attempts', 'adminkeep' ), ); return isset( $labels[ $key ] ) ? $labels[ $key ] : ''; } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( __( 'Forces the "Anyone can register" option off, by filter — so it stays off even if something flips the row in the database directly.', 'adminkeep' ), __( 'Closes the registration form and refuses unauthenticated user creation over the REST API.', 'adminkeep' ), __( 'Removes Administrator from every role dropdown in the admin — Add User, the profile screen and bulk role changes. The option is simply not offered, so nothing fails and nobody sees an error.', 'adminkeep' ), __( 'Code that tries to create an administrator directly is refused as a backstop, and any notification email about the user that was not created is suppressed with it.', 'adminkeep' ), __( 'Blocking accounts created by plugin code is available but off by default: it breaks WooCommerce checkout, membership plugins and anything else that creates customers.', 'adminkeep' ), __( 'Enforced in PHP. Anyone with filesystem or database access can remove the plugin and create users freely — this closes the common automated paths, it is not a guarantee.', 'adminkeep' ), ); } /** * Register hooks. * * @param array $settings Resolved settings. * @return void */ public function boot( array $settings ) { $this->settings = $settings; // Layer 1: beats an attacker flipping the option row directly. add_filter( 'pre_option_users_can_register', '__return_zero', 100 ); // Layer 2: close the doors. add_action( 'login_form_register', array( $this, 'block_registration_screen' ), 0 ); add_filter( 'rest_pre_dispatch', array( $this, 'block_rest_user_creation' ), 10, 3 ); // Layer 4, the smooth half: take Administrator out of every role dropdown in the admin. // This is how WordPress itself expects a role to be withheld — core checks editable_roles // before applying one, so the option simply is not offered and no error path is reached. add_filter( 'editable_roles', array( $this, 'remove_administrator_role' ), 100 ); add_action( 'user_new_form', array( $this, 'explain_missing_role' ) ); // Layers 3 and 4, the backstop: only code reaches this now, since the UI cannot ask for // an administrator. wp_pre_insert_user_data is the only filter in wp_insert_user() that // can abort — everything before it merely sanitises. add_filter( 'wp_pre_insert_user_data', array( $this, 'filter_insert_user_data' ), 100, 4 ); // Promotion of an existing user is a different path. add_filter( 'user_has_cap', array( $this, 'block_admin_promotion' ), 100, 4 ); } /** * Take Administrator out of the assignable roles. * * Core consults editable_roles before applying a role, on the Add User screen, the profile * screen and the bulk role change. Withholding it here means the choice is never offered and * nothing has to fail — which is the difference between a lock and an error message. * * @param array $roles Editable roles. * @return array */ public function remove_administrator_role( $roles ) { if ( empty( $this->settings['guard_admin'] ) || ! is_array( $roles ) ) { return $roles; } unset( $roles['administrator'] ); return $roles; } /** * Say why Administrator is missing, on the screen where someone would look for it. * * Contextual help on the form itself, not an admin notice — the plugin renders none. * * @return void */ public function explain_missing_role() { if ( empty( $this->settings['guard_admin'] ) ) { return; } printf( '

%s

', esc_html__( 'Adminkeep is withholding the Administrator role. Turn off the administrator guard in Adminkeep → Registration Lockdown to assign it.', 'adminkeep' ) ); } /** * Refuse the registration screen. * * @return void */ public function block_registration_screen() { wp_die( esc_html__( 'Registration is closed.', 'adminkeep' ), esc_html__( 'Registration is closed.', 'adminkeep' ), array( 'response' => 403 ) ); } /** * Refuse unauthenticated user creation over REST. * * @param mixed $result Response to replace the requested version with. * @param \WP_REST_Server $server Server instance. * @param \WP_REST_Request $request Request. * @return mixed */ public function block_rest_user_creation( $result, $server, $request ) { if ( null !== $result ) { return $result; } if ( ! $request instanceof \WP_REST_Request || 'POST' !== $request->get_method() ) { return $result; } if ( 0 !== strpos( (string) $request->get_route(), '/wp/v2/users' ) ) { return $result; } if ( is_user_logged_in() && current_user_can( 'create_users' ) ) { return $result; } return new \WP_Error( 'adminkeep_registration_closed', __( 'Registration is closed.', 'adminkeep' ), array( 'status' => 403 ) ); } /** * Abort user creation when a lock applies. * * Returning an empty array makes wp_insert_user() raise a WP_Error and write nothing. * * @param array $data Sanitised user data bound for the users table. * @param bool $update Whether this is an update. * @param int $user_id User id when updating. * @param array $raw The original userdata. * @return array */ public function filter_insert_user_data( $data, $update, $user_id, $raw = array() ) { if ( $update ) { return $data; } $role = ''; if ( is_array( $raw ) && isset( $raw['role'] ) ) { $role = (string) $raw['role']; } if ( ! empty( $this->settings['guard_admin'] ) && 'administrator' === $role ) { $this->record( isset( $data['user_login'] ) ? $data['user_login'] : '', 'administrator' ); return $this->abort(); } if ( ! empty( $this->settings['block_programmatic'] ) ) { return $this->abort(); } return $data; } /** * Abort the insert, and make sure nothing downstream mails about it. * * Returning empty data is how core documents aborting wp_insert_user(). It then raises a * WP_Error — but a caller that does not check for one goes on to call * wp_new_user_notification() with an id that resolves to nothing, which throws warnings and * tries to email about a user who was never created. Core's own screens check; not everything * does. So we silence the notifications for the rest of this request. * * @return array */ private function abort() { add_filter( 'wp_send_new_user_notification_to_admin', '__return_false', 100 ); add_filter( 'wp_send_new_user_notification_to_user', '__return_false', 100 ); return array(); } /** * Refuse promotion of an existing user to administrator. * * @param array $allcaps All capabilities of the user. * @param array $caps Required primitive capabilities. * @param array $args Context: [ cap, user id, ...object id ]. * @param mixed $user The user object. * @return array */ public function block_admin_promotion( $allcaps, $caps, $args, $user = null ) { if ( empty( $this->settings['guard_admin'] ) ) { return $allcaps; } if ( ! isset( $args[0] ) || 'promote_user' !== $args[0] ) { return $allcaps; } // Only block when the request would actually make someone an administrator. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- reading intent only, not acting on it. $requested = isset( $_POST['role'] ) ? sanitize_key( wp_unslash( $_POST['role'] ) ) : ''; if ( 'administrator' !== $requested ) { return $allcaps; } $allcaps['promote_user'] = false; $this->record( $user && isset( $user->user_login ) ? $user->user_login : '', 'promote' ); return $allcaps; } /** * The blocked-attempt log, newest first. * * @return array[] */ public function log() { $stored = Settings::get( $this->id() ); if ( ! isset( $stored[ self::LOG_KEY ] ) || ! is_array( $stored[ self::LOG_KEY ] ) ) { return array(); } return $stored[ self::LOG_KEY ]; } /** * Record a blocked administrator attempt. * * @param string $login Requested login. * @param string $kind 'administrator' for creation, 'promote' for promotion. * @return void */ private function record( $login, $kind ) { $entry = array( 'time' => time(), 'login' => sanitize_user( (string) $login, true ), 'kind' => $kind, 'from' => isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', ); $stored = Settings::get( $this->id() ); $log = isset( $stored[ self::LOG_KEY ] ) && is_array( $stored[ self::LOG_KEY ] ) ? $stored[ self::LOG_KEY ] : array(); array_unshift( $log, $entry ); $stored[ self::LOG_KEY ] = array_slice( $log, 0, self::LOG_LIMIT ); Settings::update( $this->id(), $stored ); } /** * Blocked administrator attempts, for the settings screen. * * @return array */ public function status() { $rows = array(); foreach ( $this->log() as $entry ) { $when = isset( $entry['time'] ) ? (int) $entry['time'] : 0; $kind = isset( $entry['kind'] ) ? $entry['kind'] : ''; $rows[] = array( $when ? wp_date( 'j M Y, H:i', $when ) : '—', array( 'text' => isset( $entry['login'] ) && '' !== $entry['login'] ? $entry['login'] : __( '(no username)', 'adminkeep' ), 'tone' => 'mono', ), 'promote' === $kind ? __( 'Tried to promote an existing user to administrator', 'adminkeep' ) : __( 'Tried to create a new administrator', 'adminkeep' ), ); } return array( 'columns' => array( __( 'When', 'adminkeep' ), __( 'Username', 'adminkeep' ), __( 'What was attempted', 'adminkeep' ), ), 'rows' => $rows, 'empty' => __( 'Nothing blocked yet. Attempts to create or promote an administrator will be listed here.', 'adminkeep' ), ); } }