WordPress plugin ‘version is higher than expected’! Oe kan da?

Nu moet ik bekennen dat ik vrij zeker was waar het probleem zich situeerde maar ik vond het interessant genoeg om een artikel aan te wijden zodat mocht je deze melding ooit tegenkomen snel kan oplossen. Maar laat ons het stap per stap analyseren.

Waarom toont WordPress de melding ‘version higher than expected’?

Er kunnen enkele redenen zijn waarom WordPress deze (fout)melding geeft:

De plugin developer heeft één of meerdere aanpassingen uitgevoerd in de core code van de plugin want eigenlijk ‘not done’ is

Het is een techniek om ervoor te zorgen dat de plugin niet meer wordt ge-update wanneer de plugin een nieuwe officiële update krijgt. Normaal gezien zet de developer die de core code heeft aangepast deze ettelijke versies hoger (bijvoorbeeld 99.9.9) wat hier niet het geval is.

De plugin is aangepast met malicious code

Om dit te dubbelchecken is het aangewezen om de plugin te checken via de checksum.

De plugin is geïnstalleerd vanuit een andere omgeving

En dat is hier het geval. de WordPress setup wordt beheerd via Composer. Dit betekent dat alle plugins worden geïnstalleerd via wpackagist.org. In dit geval gaat het over de plugin ‘ActiveCampaign Postmark for WordPress’. Als we de officiële WordPress plugin pagina bekijken dan zien we dat de recenste versie 1.19.1 is.

Maar als we de WordPress Packagist pagina raadplegen dan merken we dat de recentste versie 1.20.0 is.

En hier bevindt zich het probleem. Composer installeert standaard de recentste versie wat versie 1.20.0 is.

Okay! probleem gevonden wat nu?

Het probleem is dat we hieraan weinig kunnen doen om te vermijden dat Composer een foutieve versie installeert. Composer doet zijn ding, en correct.

Wat kunnen we dan wel doen?

Inderdaad, detecteren en informeren. Hiervoor heb ik een PHP class geschreven ‘WpPluginChecker’ genaamd. Deze PHP class heeft als argumenten slug en version. Aan de hand van deze argumenten gaat de class de recente versies ophalen via de WordPress API en vergelijkt deze met de geïnstalleerde versie.

Tevens controleert de class of de plugin naam overeenstemt met de officiële slug (name) in de WordPress API. De reden hiervoor is dat ik heb gemerkt dat in uitzonderlijke gevallen de Composer name afwijkt met de officiële slug van de plugin.

<?php
declare(strict_types=1);

use Exception;

class WpPluginChecker {
    private string $slug;
    private string $version;
    private array $plugin_api_data;
    private array $errors = array();

    public function __construct( string $slug, string $version ) {
        $this->slug    = $slug;
        $this->version = $version;

        try {
            $this->plugin_api_data = $this->fetch_plugin_data_from_api( $this->slug );
            $this->check_plugin();
        } catch ( Exception $e ) {
            $this->errors['api'] = $e->getMessage();
        }
    }

    /**
     * Fetches plugin data from the WordPress API based on the provided slug name.
     *
     * @param string $slug_name The slug name of the plugin.
     * @return array The plugin data retrieved from the API.
     * @throws Exception If the plugin API returns an invalid response or no/invalid plugin data.
     */
    private function fetch_plugin_data_from_api( string $slug_name ): array {
        $plugin_data = wp_remote_get( 'https://api.wordpress.org/plugins/info/1.0/' . $slug_name . '.json' );

        $response = wp_remote_retrieve_response_code( $plugin_data );

        if ( $response !== 200 ) {
            throw new Exception( 'The plugin API returned an invalid response' );
        }

        $plugin_data_body = wp_remote_retrieve_body( $plugin_data );
        $plugin_data      = json_decode( $plugin_data_body, true );

        if ( ! isset( $plugin_data['slug'] ) ) {
            throw new Exception( 'The plugin API returned no or invalid plugin data' );
        }

        return $plugin_data;
    }

    /**
     * Retrieves the current version of the plugin from the API data.
     *
     * @return string The current version of the plugin.
     */
    private function get_current_version_from_api(): string {
        return $this->plugin_api_data['version'] ?? null;
    }

    /**
     * Fetches and sorts the numeric versions from the plugin API data.
     *
     * @return array The sorted numeric versions.
     */
    private function fetch_and_sort_numeric_versions_from_api(): array {
        $plugin_api_data = $this->plugin_api_data;

        if ( isset( $plugin_api_data['error'] ) && ! empty( $plugin_api_data['error'] ) ) {
            return array();
        }

        $versions = $plugin_api_data['versions'];

        $versions = array_filter(
			$versions,
			function ( $key ) {
				return preg_match( '/^\d+(\.\d+)*$/', $key );
			},
			ARRAY_FILTER_USE_KEY
		);

		uksort(
			$versions,
			function ( $a, $b ) {
				return version_compare( $b, $a );
			}
		);

		return $versions;
	}

	/**
	 * Checks if the plugin slug matches the slug from the WordPress plugin API.
	 *
	 * @return bool Returns true if the slugs match, otherwise throws an Exception.
	 * @throws Exception Throws an exception if the installed plugin slug is different from the WordPress plugin API slug.
	 */
	private function check_slug_with_api(): bool|Exception {
		$slug            = $this->get_slug();
		$plugin_api_slug = $this->plugin_api_data['slug'] ?? null;

		if ( $slug !== $plugin_api_slug ) {
			throw new Exception( sprintf( 'The installed plugin slug is different from the WordPress plugin API which can lead to critical issues.', $slug, $plugin_api_slug ) );
		}

		return true;
	}

	/**
	 * Checks the current version of the plugin against the latest version available in the API.
	 *
	 * @return bool|Exception Returns true if the current version is up to date, otherwise throws an Exception.
	 * @throws Exception If no plugin versions are found or if the current version is higher than the expected latest version.
	 */
	private function check_version_in_api(): bool|Exception {
		$current_version = $this->get_current_version_from_api();

		$plugin_versions = $this->fetch_and_sort_numeric_versions_from_api();

		if ( ! $plugin_versions ) {
			throw new Exception( 'No plugin versions found' );
		}

		$latest_version = array_key_first( $plugin_versions );

		if ( $current_version < $latest_version ) {
			throw new Exception( sprintf( 'The latest plugin version is higher than the stable plugin version from the WordPress API (%s <-> %s). Please contact the plugin developer about this issue.', $latest_version, $current_version ) );
		}

		return true;
	}

	/**
	 * Checks the installed version of the plugin against the current version from the API.
	 *
	 * @return bool|Exception Returns true if the installed version is up to date, otherwise throws an Exception.
	 * @throws Exception If no plugin version is found or if the installed version is higher than the expected version.
	 */
	private function check_installed_version_with_api(): bool|Exception {
		$installed_version      = $this->version;
		$current_plugin_version = $this->get_current_version_from_api();

		if ( ! $current_plugin_version ) {
			throw new Exception( 'No plugin version found' );
		}

		if ( ! $installed_version ) {
			throw new Exception( 'No installed plugin version found' );
		}

		if ( $installed_version > $current_plugin_version ) {
			throw new Exception( sprintf( 'The installed plugin version is higher than the current version from the WordPress API (%s <-> %s). Please contact the plugin developer about this issue.', esc_attr($installed_version), esc_attr($current_plugin_version) ) );
		}

		return true;
	}

	/**
	 * Adds an error message to the specified key in the errors array.
	 *
	 * @param string $key The key to add the error message to.
	 * @param string $error The error message to add.
	 * @return void
	 */
	private function add_error( $key, $error ) {
		$this->errors[ $key ][] = $error;
	}

	/**
	 * Checks the plugin by performing various checks such as checking the plugin slug with the API,
	 * checking the plugin version in the API, and checking the installed version of the plugin with the API.
	 *
	 * @return void
	 */
	private function check_plugin(): void {
		try {
			$this->check_slug_with_api();
		} catch ( Exception $e ) {
			$this->add_error( 'api', $e->getMessage() );
		}

		try {
			$this->check_version_in_api();
		} catch ( Exception $e ) {
			$this->add_error( 'api', $e->getMessage() );
		}

		try {
			$this->check_installed_version_with_api();
		} catch ( Exception $e ) {
			$this->add_error( 'wp', $e->getMessage() );
		}
	}

	/**
	 * Retrieves the slug of the plugin.
	 *
	 * @return string The slug of the plugin.
	 */
	public function get_slug(): string {
		return $this->slug;
	}

	/**
	 * Get the version of the plugin.
	 *
	 * @return string The version of the plugin.
	 */
	public function get_version(): string {
		return $this->version;
	}

	/**
	 * Returns the errors array.
	 *
	 * @return array The errors array.
	 */
	public function get_errors(): array {
		return $this->errors;
	}
}
PHP

Je kan deze class gebruiken om bijvoorbeeld op de overzichtpagina van de plugins een extra kolom toevoegen die de foutmeldingen toont. Op deze manier kan je de gebruiker duidelijk informeren over het probleem.

Wat je ook zou kunnen doen is de foutmelding tonen via de admin_notices action hook. Meer informatie over de admin_notices action hook vind je hier.

Ik wil even benadrukken dat bovenstaande code beperkt is uitgewerkt. Bij eventuele interesse wil ik deze graag verder uitwerken tot een kleine plugin die de gevonden foutmeldingen per plugin toont.