HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
File: //wordpress/mu-plugins/wpcloud-password-protection.php
<?php

/**
 * This mu-plugin blocks compromised passwords using Bkismet API.
 *
 * @author Bastion Team
 */

// Protection from accidental double loading.
if ( ! class_exists( 'WP_Cloud_Password_Protection' ) ) {

	class WP_Cloud_Password_Protection {

		private $api_key;
		private $api_endpoint;
		private $api_timeout;
		private $fail_open;

		/**
		 * Constructor - set up hooks
		 */
		public function __construct() {
			// phpcs:ignore Universal.Operators.DisallowShortTernary.Found
			$this->api_key      = getenv( 'WPCLOUD_PASSWORD_PROTECTION_API_KEY' ) ?: 'cGXeXoJtO7Yb7hBnO4jx*PvZVrL%9u0@VlLx2xTmZd5g514YwTnUaJh7K6Z';
			$this->api_endpoint = 'https://public-api.wordpress.com/bkismet/v1/password';
			$this->api_timeout  = 5;
			$this->fail_open    = true;

			// Only register hooks if password protection is enabled and API key is configured.
			if ( ! $this->is_enabled() ) {
				return;
			}

			add_filter( 'wp_authenticate_user', array( $this, 'check_compromised_password_on_authenticate' ), 5, 2 );
			add_action( 'user_profile_update_errors', array( $this, 'check_compromised_password_on_profile_update' ), 5, 3 );
			add_action( 'validate_password_reset', array( $this, 'check_compromised_password_on_reset' ), 5, 2 );
		}

		/**
		 * Check if password protection is enabled for the site
		 *
		 * @return bool
		 */
		private function is_enabled() {
			if ( defined( 'WPC_PASSWORD_PROTECTION_ENABLED' ) ) {
				return WPC_PASSWORD_PROTECTION_ENABLED;
			}

			// Support the legacy WP Cloud-prefixed constant for existing integrations.
			if ( defined( 'WPCLOUD_ENABLED_PASSWORD_PROTECTION' ) ) {
				return WPCLOUD_ENABLED_PASSWORD_PROTECTION;
			}

			$enabled_clients = array( '18', '32', '129' );
			if ( in_array( ATOMIC_CLIENT_ID, $enabled_clients, true ) ) {
				return true;
			}

			return false;
		}

		/**
		 * Check if user role meets minimum role requirement
		 *
		 * @param WP_User|null $user User object.
		 * @return bool
		 */
		private function should_check_user_capabilities( $user ) {
			// Potentially new user, check the password anyway.
			if ( empty( $user ) || ! isset( $user->ID ) ) {
				return true;
			}

			if ( ! user_can( $user->ID, 'publish_posts' ) && ! user_can( $user->ID, 'edit_published_posts' ) ) {
				return false;
			}

			return true;
		}

		/**
		 * Check if a password is compromised using the Bkismet API
		 *
		 * @param string $password The plaintext password to check.
		 * @param string $context Optional context for logging (e.g., 'signin', 'password_change').
		 * @return int Number of times the password has been seen compromised (0 if not compromised).
		 */
		public function check_if_password_is_compromised( $password, $context = null ) {
			// Calculate SHA1 hash and split into prefix/suffix.
			$hash   = strtoupper( sha1( $password ) );
			$prefix = substr( $hash, 0, 5 );
			$suffix = substr( $hash, 5 );

			// Make API request.
			$response = $this->api_request( $prefix );

			// Handle API errors.
			if ( is_wp_error( $response ) ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
				error_log(
					sprintf(
						'Password Protection: API error in context "%s": %s in %s on line %d',
						$context,
						$response->get_error_message(),
						__FILE__,
						__LINE__
					)
				);

				// Fail open or closed based on configuration.
				if ( $this->fail_open ) {
					return 0;
				}
				return 1;
			}

			// Check if suffix is in the returned list.
			if ( isset( $response['suffixes'] ) ) {
				$suffixes = $response['suffixes'];
			} else {
				$suffixes = array();
			}
			$is_compromised = in_array( strtolower( $suffix ), array_map( 'strtolower', $suffixes ), true );

			if ( ! $is_compromised ) {
				return 0;
			}

			// Log compromised password detection.
			if ( ! is_null( $context ) ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
				error_log(
					sprintf(
						'Password Protection: Compromised password detected during context "%s" in %s on line %d',
						$context,
						__FILE__,
						__LINE__
					)
				);
			}

			return 1;
		}

		/**
		 * Make API request to Bkismet password endpoint
		 *
		 * @param string $sha1_prefix First 5 characters of SHA1 hash.
		 * @return array|WP_Error Response data array or WP_Error on failure.
		 */
		private function api_request( $sha1_prefix ) {
			$response = wp_remote_post(
				$this->api_endpoint,
				array(
					'timeout' => $this->api_timeout,
					'headers' => array(
						'X-API-Key'    => $this->api_key,
						'Content-Type' => 'application/x-www-form-urlencoded',
					),
					'body'    => array(
						'prefix' => $sha1_prefix,
					),
				)
			);

			// Check for network errors.
			if ( is_wp_error( $response ) ) {
				return $response;
			}

			// Check response code.
			$response_code = wp_remote_retrieve_response_code( $response );
			if ( 200 !== $response_code ) {
				return new WP_Error(
					'bkismet_api_error',
					sprintf( 'API returned status code %d: %s', $response_code, $response['body'] )
				);
			}

			// Parse response body.
			$body = wp_remote_retrieve_body( $response );
			$data = json_decode( $body, true );

			// Validate response structure.
			if ( ! is_array( $data ) || ! isset( $data['data'] ) ) {
				return new WP_Error(
					'bkismet_api_invalid_response',
					'Invalid API response format'
				);
			}

			return $data['data'];
		}

		/**
		 * Block login attempts with compromised passwords
		 *
		 * This hook runs on wp_authenticate_user filter, which is called after password validation
		 * but before other authentication checks.
		 *
		 * @param WP_User|WP_Error $user     WP_User object or WP_Error if authentication failed.
		 * @param string           $password Password to check against the user.
		 * @return WP_User|WP_Error User object or error.
		 */
		public function check_compromised_password_on_authenticate( $user, $password ) {
			// Skip if already an error or no valid user.
			if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) || empty( $password ) ) {
				return $user;
			}

			// Check if user role meets minimum requirement.
			if ( ! $this->should_check_user_capabilities( $user ) ) {
				return $user;
			}

			// Check if password is compromised.
			$compromised = $this->check_if_password_is_compromised( $password, 'signin' );

			if ( ! $compromised ) {
				return $user;
			}

			// Log the compromised login attempt.
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log(
				sprintf(
					'Password Protection: Blocked login for user "%s" (ID: %d) with compromised password in %s on line %d',
					$user->user_login,
					$user->ID,
					__FILE__,
					__LINE__
				)
			);

			// Return error to block login.
			return new WP_Error(
				'compromisable_account',
				sprintf(
					// translators: %s - lost password url.
					__( 'Your account has been blocked as a security precaution. To continue, you must <a href="%s">reset your password</a>.' ),
					wp_lostpassword_url()
				)
			);
		}

		/**
		 * Validate password on user profile update
		 *
		 * @param WP_Error $errors Error object.
		 * @param bool     $update Whether this is an update.
		 * @param WP_User  $user User object.
		 */
		public function check_compromised_password_on_profile_update( $errors, $update, $user ) {
			// Only check if a new password is being set.
			if ( empty( $_POST['pass1'] ) ) {
				return;
			}

			if ( ! $this->should_check_user_capabilities( $user ) ) {
				return;
			}

			$password = wp_unslash( $_POST['pass1'] );

			// Check if password is compromised.
			$compromised = $this->check_if_password_is_compromised( $password, 'password_change' );

			if ( ! $compromised ) {
				return;
			}

			$errors->add(
				'pass',
				__( '<strong>Error</strong>: This password is known to be included in compromised password lists. Please choose something more unique.' )
			);
		}

		/**
		 * Validate password on password reset
		 *
		 * @param WP_Error $errors Error object.
		 * @param WP_User  $user User object.
		 */
		public function check_compromised_password_on_reset( $errors, $user ) {
			// Only check if a new password is being set.
			if ( empty( $_POST['pass1'] ) ) {
				return;
			}

			// Check if user role meets minimum requirement.
			if ( ! $this->should_check_user_capabilities( $user ) ) {
				return;
			}

			$password = wp_unslash( $_POST['pass1'] );

			// Check if password is compromised.
			$compromised = $this->check_if_password_is_compromised( $password, 'password_reset' );

			if ( ! $compromised ) {
				return;
			}

			$errors->add(
				'pass',
				__( '<strong>Error</strong>: This password is known to be included in compromised password lists. Please choose something more unique.' )
			);
		}
	}

	new WP_Cloud_Password_Protection();
}