|\WP_Error */ public function complete_ownership( $challenge_id, $verifier, $proof ); /** @return array|\WP_Error */ public function complete_ownership_dns( $launch, $verifier ); /** @return array|\WP_Error */ public function fetch_widget_release( $project_id, $timeout = 10 ); /** @return array|\WP_Error */ public function disconnect( $binding ); /** @return array|\WP_Error */ public function heartbeat( $binding, $fields, $write = true ); /** @return array|\WP_Error */ public function status( $binding, $timeout = 3 ); /** @return array|\WP_Error */ public function diagnostics( $binding, $fields ); } final class Api_Client implements Api_Client_Interface { use Exact_Keys; const MAX_RESPONSE_BYTES = 32768; const MAX_LIFECYCLE_BYTES = 4096; /** @return array|\WP_Error */ public function complete_ownership( $challenge_id, $verifier, $proof ) { return $this->post_json( ACHLAAI_APP_URL . '/api/plugin/ownership/complete', array( 'protocol' => Protocol::OWNERSHIP_PROTOCOL, 'challenge_id' => $challenge_id, 'verifier' => $verifier, 'proof' => $proof, ) ); } /** @return array|\WP_Error */ public function complete_ownership_dns( $launch, $verifier ) { $body = array( 'protocol' => $launch['protocol'], 'attempt_id' => $launch['attempt_id'], 'state' => $launch['state'], 'code_challenge' => $launch['code_challenge'], 'code_challenge_method' => $launch['code_challenge_method'], 'callback_uri' => $launch['callback_uri'], 'site' => $launch['site'], 'finish_uri' => $launch['finish_uri'], 'verifier' => $verifier, ); $response = wp_safe_remote_post( ACHLAAI_APP_URL . '/api/plugin/ownership/dns/complete', array( 'timeout' => 6, 'redirection' => 0, 'reject_unsafe_urls' => true, 'sslverify' => true, 'limit_response_size' => self::MAX_RESPONSE_BYTES, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); if ( ! is_wp_error( $response ) && 202 === (int) wp_remote_retrieve_response_code( $response ) ) { return new \WP_Error( 'dns_not_ready', __( 'DNS ownership verification is still pending.', 'achlaai' ), array( 'status' => 202 ) ); } return $this->decode_response( $response ); } /** @return array|\WP_Error */ public function fetch_widget_release( $project_id, $timeout = 10 ) { $url = add_query_arg( array( 'projectId' => $project_id, 'bridgeVersion' => ACHLAAI_BRIDGE_VERSION, ), ACHLAAI_APP_URL . '/api/widget-release/manifest' ); $response = wp_safe_remote_get( $url, array( 'timeout' => max( 1, min( 10, (float) $timeout ) ), 'redirection' => 0, 'reject_unsafe_urls' => true, 'sslverify' => true, 'limit_response_size' => self::MAX_RESPONSE_BYTES, 'headers' => array( 'Accept' => 'application/json', 'X-Aisx-Bridge-Version' => ACHLAAI_BRIDGE_VERSION, ), ) ); if ( ! is_wp_error( $response ) && 503 === (int) wp_remote_retrieve_response_code( $response ) ) { $raw = wp_remote_retrieve_body( $response ); $decoded = is_string( $raw ) && strlen( $raw ) <= self::MAX_RESPONSE_BYTES ? json_decode( $raw, true ) : null; if ( $this->has_exact_keys( $decoded, array( 'error', 'expired_at' ) ) && 'control_expired' === $decoded['error'] && null !== Protocol::parse_canonical_time( $decoded['expired_at'] ) ) { return new \WP_Error( 'release_control_expired', __( 'The release was not re-signed by the vendor.', 'achlaai' ), array( 'status' => 503, 'expired_at' => $decoded['expired_at'], ) ); } } return $this->decode_response( $response ); } /** @return array|\WP_Error */ public function disconnect( $binding ) { $response = $this->management_request( $binding, 'POST', 'disconnect_path', array( 'protocol' => 'aip-management-v1', ), 3 ); if ( is_wp_error( $response ) ) { return $response; } if ( ! $this->has_exact_keys( $response, array( 'ok', 'status', 'source', 'disconnected_at' ) ) || true !== $response['ok'] || 'disconnected' !== $response['status'] || 'local' !== $response['source'] || null === Protocol::parse_canonical_time( $response['disconnected_at'] ) ) { return $this->bad_lifecycle_response(); } return $response; } /** @return array|\WP_Error */ public function heartbeat( $binding, $fields, $write = true ) { if ( ! $write ) { return $this->status( $binding ); } if ( ! $this->valid_heartbeat_fields( $fields ) ) { return new \WP_Error( 'invalid_heartbeat', __( 'The heartbeat fields are invalid.', 'achlaai' ), array( 'status' => 400 ) ); } $response = $this->management_request( $binding, 'POST', 'heartbeat_path', $fields, 4 ); return is_wp_error( $response ) ? $response : $this->validate_management_status( $response, $binding, true ); } /** @return array|\WP_Error */ public function status( $binding, $timeout = 3 ) { $response = $this->management_request( $binding, 'GET', 'heartbeat_path', null, $timeout ); return is_wp_error( $response ) ? $response : $this->validate_management_status( $response, $binding, false ); } /** @return array|\WP_Error */ public function diagnostics( $binding, $fields ) { if ( ! $this->valid_diagnostics_fields( $fields ) ) { return new \WP_Error( 'invalid_diagnostics', __( 'The diagnostics fields are invalid.', 'achlaai' ), array( 'status' => 400 ) ); } $response = $this->management_request( $binding, 'POST', 'diagnostics_path', $fields, 4 ); if ( is_wp_error( $response ) ) { return $response; } return $this->has_exact_keys( $response, array( 'ok', 'accepted' ) ) && true === $response['ok'] && true === $response['accepted'] ? $response : $this->bad_lifecycle_response(); } /** * @param string $url Fixed Achla endpoint. * @param array $body JSON body. * @return array|\WP_Error */ private function post_json( $url, $body ) { $response = wp_safe_remote_post( $url, array( 'timeout' => 6, 'redirection' => 0, 'reject_unsafe_urls' => true, 'sslverify' => true, 'limit_response_size' => self::MAX_RESPONSE_BYTES, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); return $this->decode_response( $response ); } /** * @param array $binding Stored verified binding. * @param string $method GET or POST. * @param string $path_key Validated management path key. * @param array|null $body Exact allowlisted body. * @param float $timeout Bounded request timeout. * @return array|\WP_Error */ private function management_request( $binding, $method, $path_key, $body, $timeout ) { $management = isset( $binding['management'] ) && is_array( $binding['management'] ) ? $binding['management'] : array(); $paths = array( 'heartbeat_path' => '/api/plugin/heartbeat', 'disconnect_path' => '/api/plugin/disconnect', 'diagnostics_path' => '/api/plugin/diagnostics', ); if ( ! isset( $paths[ $path_key ], $management[ $path_key ] ) || $paths[ $path_key ] !== $management[ $path_key ] || 'aip-management-v1' !== ( isset( $management['protocol'] ) ? $management['protocol'] : '' ) || 'Bearer' !== ( isset( $management['token_type'] ) ? $management['token_type'] : '' ) || ! isset( $management['token'] ) || ! is_string( $management['token'] ) || ! preg_match( '/^aipm_[A-Za-z0-9_-]{43}$/', $management['token'] ) || ! isset( $binding['project_id'], $binding['binding_id'], $binding['installation_id'] ) || ! Protocol::is_identifier( $binding['project_id'] ) || strlen( $binding['project_id'] ) > 128 || ! Protocol::is_identifier( $binding['binding_id'] ) || strlen( $binding['binding_id'] ) > 128 || ! Protocol::is_identifier( $binding['installation_id'] ) || strlen( $binding['installation_id'] ) > 128 ) { return new \WP_Error( 'invalid_management_binding', __( 'The local management binding is invalid. Reconnect this site.', 'achlaai' ), array( 'status' => 409 ) ); } $headers = array( 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $management['token'], 'X-Achla-Project-Id' => $binding['project_id'], 'X-Achla-Binding-Id' => $binding['binding_id'], 'X-Achla-Installation-Id' => $binding['installation_id'], ); $args = array( 'timeout' => max( 1, min( 8, (float) $timeout ) ), 'redirection' => 0, 'reject_unsafe_urls' => true, 'sslverify' => true, 'limit_response_size' => self::MAX_LIFECYCLE_BYTES, 'headers' => $headers, ); $url = ACHLAAI_APP_URL . $paths[ $path_key ]; if ( 'POST' === $method ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); $response = wp_safe_remote_post( $url, $args ); } else { $response = wp_safe_remote_get( $url, $args ); } return $this->decode_response( $response ); } /** @return array|\WP_Error */ private function validate_management_status( $response, $binding, $heartbeat_write ) { $keys = array( 'ok', 'protocol', 'project_id', 'binding_id', 'installation_id', 'credential_status', 'last_seen_at', 'stale_at', 'heartbeat_interval_seconds', 'project', 'heartbeat' ); if ( $heartbeat_write ) { $keys[] = 'recorded'; $keys[] = 'write_after_seconds'; } if ( ! $this->has_exact_keys( $response, $keys ) || true !== $response['ok'] || 'aip-management-v1' !== $response['protocol'] || ! Crypto::safe_equal( $response['project_id'], $binding['project_id'] ) || ! Crypto::safe_equal( $response['binding_id'], $binding['binding_id'] ) || ! Crypto::safe_equal( $response['installation_id'], $binding['installation_id'] ) || 'active' !== $response['credential_status'] || 3600 !== $response['heartbeat_interval_seconds'] || null === Protocol::parse_canonical_time( $response['last_seen_at'] ) || null === Protocol::parse_canonical_time( $response['stale_at'] ) || ! $this->valid_project_status( $response['project'] ) || ! $this->valid_reported_heartbeat( $response['heartbeat'] ) ) { return $this->bad_lifecycle_response(); } if ( Protocol::parse_canonical_time( $response['stale_at'] ) <= Protocol::parse_canonical_time( $response['last_seen_at'] ) ) { return $this->bad_lifecycle_response(); } if ( $heartbeat_write && ( ! is_bool( $response['recorded'] ) || ! is_int( $response['write_after_seconds'] ) || $response['write_after_seconds'] < 0 || $response['write_after_seconds'] > 300 ) ) { return $this->bad_lifecycle_response(); } return $response; } /** @return bool */ private function valid_project_status( $project ) { return $this->has_exact_keys( $project, array( 'status', 'index_status' ) ) && in_array( $project['status'], array( 'registered', 'pending_payment', 'active', 'past_due', 'suspended', 'canceled' ), true ) && in_array( $project['index_status'], array( 'pending', 'indexing', 'ready', 'failed' ), true ); } /** @return bool */ private function valid_reported_heartbeat( $heartbeat ) { if ( ! $this->has_exact_keys( $heartbeat, array( 'plugin_version', 'cms', 'cms_version', 'runtime', 'runtime_version', 'connection_status', 'index_status', 'health_status' ) ) ) { return false; } return $this->nullable_version( $heartbeat['plugin_version'] ) && ( null === $heartbeat['cms'] || in_array( $heartbeat['cms'], array( 'wordpress', 'drupal' ), true ) ) && $this->nullable_version( $heartbeat['cms_version'] ) && ( null === $heartbeat['runtime'] || 'php' === $heartbeat['runtime'] ) && $this->nullable_version( $heartbeat['runtime_version'] ) && ( null === $heartbeat['connection_status'] || in_array( $heartbeat['connection_status'], array( 'connected', 'degraded', 'error' ), true ) ) && ( null === $heartbeat['index_status'] || in_array( $heartbeat['index_status'], array( 'unknown', 'pending', 'indexing', 'ready', 'failed' ), true ) ) && in_array( $heartbeat['health_status'], array( 'unknown', 'healthy', 'degraded', 'error' ), true ); } /** @return bool */ private function nullable_version( $value ) { return null === $value || $this->valid_version( $value ); } /** @return \WP_Error */ private function bad_lifecycle_response() { return new \WP_Error( 'invalid_lifecycle_response', __( 'Achla returned an invalid lifecycle response.', 'achlaai' ), array( 'status' => 502 ) ); } /** @return bool */ private function valid_heartbeat_fields( $fields ) { if ( ! $this->has_exact_keys( $fields, array( 'protocol', 'plugin_version', 'cms', 'cms_version', 'runtime', 'runtime_version', 'connection_status', 'index_status' ) ) ) { return false; } return 'aip-management-v1' === $fields['protocol'] && $this->valid_version( $fields['plugin_version'] ) && in_array( $fields['cms'], array( 'wordpress', 'drupal' ), true ) && $this->valid_version( $fields['cms_version'] ) && 'php' === $fields['runtime'] && $this->valid_version( $fields['runtime_version'] ) && in_array( $fields['connection_status'], array( 'connected', 'degraded', 'error' ), true ) && in_array( $fields['index_status'], array( 'unknown', 'pending', 'indexing', 'ready', 'failed' ), true ); } /** @return bool */ private function valid_diagnostics_fields( $fields ) { if ( ! $this->has_exact_keys( $fields, array( 'protocol', 'plugin_version', 'cms', 'cms_version', 'runtime', 'runtime_version', 'connection_status', 'index_status', 'error_code', 'observed_at', 'error_first_seen_at', 'error_last_seen_at' ) ) ) { return false; } $heartbeat = $fields; unset( $heartbeat['error_code'], $heartbeat['observed_at'], $heartbeat['error_first_seen_at'], $heartbeat['error_last_seen_at'] ); if ( ! $this->valid_heartbeat_fields( $heartbeat ) ) { return false; } foreach ( $fields as $value ) { if ( is_string( $value ) && $this->contains_sensitive_diagnostic( $value ) ) { return false; } } if ( null === $fields['error_code'] ) { $observed = Protocol::parse_canonical_time( $fields['observed_at'] ); return null !== $observed && $observed <= time() + 300 && null === $fields['error_first_seen_at'] && null === $fields['error_last_seen_at']; } $observed = Protocol::parse_canonical_time( $fields['observed_at'] ); $first = Protocol::parse_canonical_time( $fields['error_first_seen_at'] ); $last = Protocol::parse_canonical_time( $fields['error_last_seen_at'] ); return in_array( $fields['error_code'], array( 'ATTACH_TARGET_MISSING', 'RELEASE_UNAVAILABLE' ), true ) && null !== $observed && null !== $first && null !== $last && $first <= $last && $observed <= time() + 300 && $first <= time() + 300 && $last <= time() + 300; } /** @return bool */ private function valid_version( $value ) { return is_string( $value ) && strlen( $value ) <= 40 && false === stripos( $value, 'aipm_' ) && 1 === preg_match( '/^(?:v?(?:0|[1-9]\d{0,3})(?:\.(?:0|[1-9]\d{0,3})){0,3}(?:(?:-?(?:alpha|beta|rc|dev|preview)(?:[.-]?(?:0|[1-9]\d{0,2}))?)|(?:[-+~](?=[A-Za-z0-9.+~_-]*\d)[A-Za-z0-9][A-Za-z0-9.+~_-]*))?|v?(?:0|[1-9]\d{0,3})(?:\.(?:0|[1-9]\d{0,3})){0,2}\.x-dev)$/Di', $value ); } /** @return bool */ private function contains_sensitive_diagnostic( $value ) { $patterns = array( '/aipm_[A-Za-z0-9_-]{43}/i', '/https?:\/\//i', '/\bwww\./i', '/\b[^\s@]+@[^\s@]+\.[^\s@]+\b/', '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '/\bBearer\s+[A-Za-z0-9._-]+/i', '/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/', '/(?:^|[?&])[A-Za-z0-9_.-]+=/', '/(?:cookie|session|token|secret|password|authorization|auth|query|body|response|stack|trace|content)/i', ); foreach ( $patterns as $pattern ) { if ( preg_match( $pattern, $value ) ) { return true; } } return false; } /** * @param array|\WP_Error $response HTTP response. * @return array|\WP_Error */ private function decode_response( $response, $allow_empty = false ) { if ( is_wp_error( $response ) ) { return new \WP_Error( 'achla_network_error', __( 'Achla could not be reached securely. Try again.', 'achlaai' ), array( 'status' => 502 ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = wp_remote_retrieve_body( $response ); if ( $status < 200 || $status >= 300 ) { $code = 426 === $status ? 'incompatible_release' : 'achla_http_error'; return new \WP_Error( $code, __( 'Achla rejected the secure request. Try again or contact support.', 'achlaai' ), array( 'status' => $status ) ); } if ( ! is_string( $raw ) || strlen( $raw ) > self::MAX_RESPONSE_BYTES ) { return new \WP_Error( 'achla_bad_response', __( 'Achla returned an invalid response.', 'achlaai' ), array( 'status' => 502 ) ); } if ( $allow_empty && '' === $raw ) { return array( 'ok' => true ); } $decoded = json_decode( $raw, true ); if ( ! is_array( $decoded ) ) { return new \WP_Error( 'achla_bad_response', __( 'Achla returned an invalid response.', 'achlaai' ), array( 'status' => 502 ) ); } return $decoded; } }