= self::MAX_FAILS ) { throw new Guard_Error( 'locked_out', 429, 'Too many failed attempts. Try again in 30 minutes.' ); } // phpcs:disable WordPress.Security.NonceVerification.Missing -- Authenticated by HMAC signature instead. $env_raw = isset( $_POST['env'] ) ? (string) wp_unslash( $_POST['env'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Signed opaque base64 text. $sig = isset( $_POST['sig'] ) ? strtolower( (string) wp_unslash( $_POST['sig'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized // phpcs:enable WordPress.Security.NonceVerification.Missing if ( strlen( $env_raw ) > self::MAX_ENVELOPE ) { throw new Guard_Error( 'too_large', 413, 'The request was too large.' ); } if ( '' === $env_raw || ! preg_match( '/^[A-Za-z0-9_-]+$/', $env_raw ) ) { self::fail( $ip, 'malformed' ); } $env = json_decode( (string) Secrets::b64url_decode( $env_raw ), true ); if ( ! is_array( $env ) || ! isset( $env['v'], $env['kid'], $env['ts'], $env['nonce'], $env['op'] ) ) { self::fail( $ip, 'malformed' ); } if ( (int) $env['v'] !== ACCESSNOW_REPLICA_PROTOCOL ) { throw new Guard_Error( 'protocol_mismatch', 400, 'This site runs a different Site Replica protocol version. Update the plugin on both sites.' ); } $key = Connection_Keys::find( (string) $env['kid'] ); if ( null === $key ) { self::fail( $ip, 'bad_key' ); } if ( ! Connection_Keys::ip_allowed( $ip, $key['ips'] ) ) { self::fail( $ip, 'ip_not_allowed' ); } if ( ! Signer::verify( $key['secret_raw'], $env_raw, $sig ) ) { self::fail( $ip, 'bad_signature' ); } if ( abs( time() - (int) $env['ts'] ) > self::WINDOW ) { throw new Guard_Error( 'clock_skew', 409, 'Clock difference too large.', array( 'server_time' => time() ) ); } if ( ! is_string( $env['nonce'] ) || ! preg_match( '/^[A-Za-z0-9_-]{16,64}$/', $env['nonce'] ) || ! self::claim_nonce( $key['kid'], $env['nonce'], (int) $env['ts'] ) ) { self::fail( $ip, 'replay' ); } if ( ! self::claim_slot( $key['kid'] ) ) { throw new Guard_Error( 'busy', 429, 'Too many requests at once for this key.' ); } Connection_Keys::touch( $key['kid'], $ip ); return array( $env, $key ); } /** * HTTPS required, except for local development hosts or HTTPS terminated at a proxy. */ private static function transport_ok() { if ( is_ssl() || Environment::is_local_site() ) { return true; } $forwarded = isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) ) : ''; $cf = isset( $_SERVER['HTTP_CF_VISITOR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_VISITOR'] ) ) : ''; return 'https' === $forwarded || false !== strpos( $cf, 'https' ); } /** * Client IP (REMOTE_ADDR only: forwarded headers can be forged). */ public static function client_ip() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; } /** * Records a failed attempt and refuses the request. * * @param string $ip IP. * @param string $reason Reason code (logged, not revealed). * @throws Guard_Error Always. */ private static function fail( $ip, $reason ) { $key = 'accessnow_replica_fail_' . md5( $ip ); $count = (int) get_transient( $key ) + 1; set_transient( $key, $count, self::FAIL_WINDOW ); Logger::audit( 'Refused request.', array( 'reason' => $reason, 'ip' => $ip ) ); throw new Guard_Error( 'unauthorized', 401, 'The connection key was not accepted.' ); } /** * Failed attempts for an IP in the current window. * * @param string $ip IP. */ private static function fail_count( $ip ) { return (int) get_transient( 'accessnow_replica_fail_' . md5( $ip ) ); } /** * Atomically records a nonce; false if it was already used. * * @param string $kid Key ID. * @param string $nonce Nonce. * @param int $ts Request time. */ private static function claim_nonce( $kid, $nonce, $ts ) { $base = Storage::path( 'source/nonces/' ); $bucket = (int) floor( $ts / 60 ); $dir = $base . $bucket; if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } $fh = @fopen( $dir . '/' . sha1( $kid . '|' . $nonce ), 'x' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( false === $fh ) { return false; } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose // Occasionally delete buckets older than the replay window. if ( 1 === wp_rand( 1, 20 ) ) { $oldest = (int) floor( ( time() - self::WINDOW * 2 ) / 60 ); foreach ( (array) @scandir( $base ) as $name ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( ctype_digit( (string) $name ) && (int) $name < $oldest ) { \AccessNow\Replica\Util\Fs::rrmdir( $base . $name ); } } } return true; } /** * Releases this request's slot once its answer is built: sending it can take a minute on * hosts that throttle connections, and must not block the next request. */ public static function release_slot() { if ( self::$slot ) { flock( self::$slot, LOCK_UN ); fclose( self::$slot ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose self::$slot = null; } } /** * Takes one of the per-key concurrency slots (protects the host's process limits). * * @param string $kid Key ID. */ private static function claim_slot( $kid ) { $dir = Storage::path( 'source/slots/' ); if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } for ( $i = 0; $i < self::SLOTS; $i++ ) { $fh = @fopen( $dir . $kid . '-' . $i . '.lock', 'c' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( $fh && flock( $fh, LOCK_EX | LOCK_NB ) ) { self::$slot = $fh; return true; } if ( $fh ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } } return false; } }