File: //wordpress/mu-plugins/wpcloud-bot-protection/wpcloud-bot-protection.php
<?php
/**
* Plugin Name: WP Cloud Bot Protection (Blackbox)
* Description: Gates wp-login.php authentication on a Blackbox verdict.
* Version: 0.1.0
* Author: Bastion
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'WP_Cloud_Bot_Protection' ) ) {
class WP_Cloud_Bot_Protection {
const JS_URL = 'https://blackbox-api.wp.com/v1/dist/v.js';
const API_BASE = 'https://blackbox-api.wp.com';
const TIMEOUT = 2.0;
const FIELD = 'wpcloud_bb_session';
const VERDICT_ALLOW = 'allow';
const VERDICT_CHALLENGE = 'challenge';
const VERDICT_BLOCK = 'block';
const VERDICT_ERROR = 'error';
private static $verdict_cache = [];
public static function boot(): void {
add_action( 'login_enqueue_scripts', [ __CLASS__, 'enqueue_client' ] );
add_action( 'login_form', [ __CLASS__, 'render_form_hooks' ] );
add_action( 'lostpassword_form', [ __CLASS__, 'render_form_hooks' ] );
add_action( 'lostpassword_post', [ __CLASS__, 'gate_password_reset' ], 10, 2 );
add_filter( 'authenticate', [ __CLASS__, 'gate_authentication' ], 40, 3 );
}
public static function enqueue_client(): void {
if ( ! self::configured() ) {
return;
}
wp_enqueue_script(
'wpcloud-blackbox-client',
self::JS_URL,
[],
null,
[
'in_footer' => true,
'strategy' => 'defer',
]
);
$bootstrap_version = filemtime( __DIR__ . '/assets/bootstrap.js' );
if ( ! $bootstrap_version ) {
$bootstrap_version = '0.1.0';
}
wp_enqueue_script(
'wpcloud-blackbox-bootstrap',
plugins_url( 'assets/bootstrap.js', __FILE__ ),
[ 'wpcloud-blackbox-client' ],
(string) $bootstrap_version,
[
'in_footer' => true,
'strategy' => 'defer',
]
);
wp_add_inline_script(
'wpcloud-blackbox-bootstrap',
sprintf(
'window.__wpcloudBlackbox = { apiKey: %s, field: %s };',
wp_json_encode( self::public_key() ),
wp_json_encode( self::FIELD )
),
'before'
);
}
public static function render_form_hooks(): void {
if ( ! self::configured() ) {
return;
}
printf(
'<input type="hidden" name="%1$s" id="%1$s" value="" />',
esc_attr( self::FIELD )
);
echo '<div id="wpcloud-blackbox-challenge" style="margin:1em 0;"></div>';
}
public static function gate_authentication( $user, $username, $password ) {
if ( ! self::configured() ) {
return $user;
}
if ( ! self::is_login_form_post() ) {
return $user;
}
if ( '' === $username || null === $username ) {
return $user;
}
$verdict = self::verdict_for(
self::session_id_from_post(),
$username,
[
'action' => 'wp_login',
'login_method' => 'password',
]
);
switch ( $verdict ) {
case self::VERDICT_BLOCK:
return new WP_Error(
'wpcloud_blackbox_blocked',
__( '<strong>Error:</strong> Sign-in blocked. If this looks wrong, contact support.', 'wpcloud-bot-protection' )
);
case self::VERDICT_CHALLENGE:
return new WP_Error( 'wpcloud_blackbox_challenge', self::challenge_message() );
case self::VERDICT_ALLOW:
case self::VERDICT_ERROR:
default:
return $user;
}
}
public static function gate_password_reset( $errors, $user_data = null ): void {
if ( ! self::configured() ) {
return;
}
if ( ! is_wp_error( $errors ) ) {
return;
}
if ( ! self::is_login_form_post() ) {
return;
}
$verdict = self::verdict_for(
self::session_id_from_post(),
'lostpassword',
[
'action' => 'password_reset',
]
);
switch ( $verdict ) {
case self::VERDICT_BLOCK:
$errors->add(
'wpcloud_blackbox_blocked',
__( '<strong>Error:</strong> Password reset blocked. If this looks wrong, contact support.', 'wpcloud-bot-protection' )
);
break;
case self::VERDICT_CHALLENGE:
$errors->add( 'wpcloud_blackbox_challenge', self::challenge_message() );
break;
case self::VERDICT_ALLOW:
case self::VERDICT_ERROR:
default:
break;
}
}
private static function verdict_for( string $session_id, string $discriminator, array $context ): string {
$cache_key = $session_id . '|' . $discriminator;
if ( ! isset( self::$verdict_cache[ $cache_key ] ) ) {
self::$verdict_cache[ $cache_key ] = self::verify( $session_id, $context );
}
return self::$verdict_cache[ $cache_key ];
}
private static function session_id_from_post(): string {
return isset( $_POST[ self::FIELD ] )
? sanitize_text_field( wp_unslash( $_POST[ self::FIELD ] ) )
: '';
}
private static function challenge_message(): string {
return __( '<strong>Error:</strong> Please complete the verification step and try again.', 'wpcloud-bot-protection' );
}
private static function verify( string $session_id, array $context ): string {
$url = '' !== $session_id
? self::API_BASE . '/v1/verify/' . rawurlencode( $session_id )
: self::API_BASE . '/v1/verify';
$payload = [ 'context' => $context ];
if ( '' === $session_id ) {
$visitor_ip = self::visitor_ip();
if ( '' !== $visitor_ip ) {
$payload['visitor_ip'] = $visitor_ip;
}
$payload['full_headers'] = self::request_headers();
}
$request_body = (string) wp_json_encode( $payload );
$response = wp_remote_post(
$url,
[
'timeout' => self::TIMEOUT,
'headers' => array_merge(
[
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
self::signed_auth_headers( 'POST', $url, $request_body )
),
'body' => $request_body,
]
);
if ( is_wp_error( $response ) ) {
error_log( 'Bot Protection: verify transport error: ' . $response->get_error_message() );
return self::VERDICT_ERROR;
}
$status = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$data = is_array( $body ) && isset( $body['data'] ) && is_array( $body['data'] ) ? $body['data'] : [];
if ( 409 === $status ) {
return isset( $data['decision'] )
? self::normalize_decision( $data['decision'] )
: self::VERDICT_ALLOW;
}
if ( $status < 200 || $status >= 300 ) {
error_log( sprintf( 'Bot Protection: verify HTTP %d: %s', $status, wp_remote_retrieve_body( $response ) ) );
return self::VERDICT_ERROR;
}
return self::normalize_decision( $data['decision'] ?? '' );
}
private static function visitor_ip(): string {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : '';
return is_string( $ip ) && false !== filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
}
private static function request_headers(): array {
if ( function_exists( 'getallheaders' ) ) {
$headers = getallheaders();
return is_array( $headers ) ? $headers : [];
}
$headers = [];
foreach ( $_SERVER as $name => $value ) {
if ( 0 === strpos( (string) $name, 'HTTP_' ) ) {
$key = str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', substr( $name, 5 ) ) ) ) );
$headers[ $key ] = $value;
}
}
return $headers;
}
private static function normalize_decision( $decision ): string {
$decision = is_string( $decision ) ? strtolower( $decision ) : '';
$valid = [ self::VERDICT_ALLOW, self::VERDICT_CHALLENGE, self::VERDICT_BLOCK, self::VERDICT_ERROR ];
return in_array( $decision, $valid, true ) ? $decision : self::VERDICT_ERROR;
}
private static function is_login_form_post(): bool {
return 'POST' === ( $_SERVER['REQUEST_METHOD'] ?? '' )
&& isset( $GLOBALS['pagenow'] )
&& 'wp-login.php' === $GLOBALS['pagenow'];
}
private static function site_id(): string {
return defined( 'ATOMIC_SITE_ID' ) ? (string) constant( 'ATOMIC_SITE_ID' ) : '';
}
private static function site_api_key(): string {
return defined( 'ATOMIC_SITE_API_KEY' ) ? (string) constant( 'ATOMIC_SITE_API_KEY' ) : '';
}
private static function public_key(): string {
return 'wpcloud:' . self::site_id();
}
private static function signed_auth_headers( string $method, string $url, string $body ): array {
$timestamp = (string) time();
$nonce = bin2hex( random_bytes( 16 ) );
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
$canonical = implode( "\n", [ $timestamp, $nonce, strtoupper( $method ), $path, hash( 'sha256', $body ) ] );
$signature = hash_hmac( 'sha256', $canonical, self::site_api_key() );
return [
'X-WPCLOUD-Site-Id' => self::site_id(),
'X-WPCLOUD-Timestamp' => $timestamp,
'X-WPCLOUD-Nonce' => $nonce,
'X-WPCLOUD-Signature' => $signature,
];
}
private static function configured(): bool {
return '' !== self::site_id() && '' !== self::site_api_key();
}
}
WP_Cloud_Bot_Protection::boot();
}