/.json * (WP-CLI's `plugin verify-checksums`). * * These are the ONLY external requests the integrity scan makes, and only a * running scan makes them -- one the owner started, or the daily scan they * switched on. HTTPS only, through wp_safe_remote_get(). Nothing about the * site is sent: the URL carries a version number, a locale and a plugin's * public slug, all three of which the site already announces to * wordpress.org on every update check. * * Cached per version, by the caller: a unit's checksums become its rows in * the Store, and are fetched again only when the unit's version changes. A * definite "no checksums for this plugin version" (HTTP 404, the answer for * any plugin not hosted on wordpress.org) is remembered in MISSES_OPTION, so * a premium or custom plugin costs one request per version, ever. * * Every response is untrusted input: a path is kept only when * Comparison::is_safe_relative() accepts it and a hash only when it is an * md5, and both lists are capped. */ final class Checksums { public const CORE_URL = 'https://api.wordpress.org/core/checksums/1.0/'; public const PLUGIN_URL = 'https://downloads.wordpress.org/plugin-checksums/'; /** slug@version => 1, for plugin versions wordpress.org has no checksums for. */ public const MISSES_OPTION = 'ops404_al_integrity_misses'; /** Most remembered misses. */ public const MAX_MISSES = 500; /** Most files one response may list. */ public const MAX_FILES = 20000; /** Seconds one request may take. */ public const TIMEOUT = 15; /** A fetch that answered with checksums. */ public const FOUND = 'found'; /** A definite "none published" (cached as a miss for plugins). */ public const NONE = 'none'; /** A failure worth retrying next scan: network error, 5xx, garbage. */ public const ERROR = 'error'; /** * Core's checksums for a version, keyed by path relative to ABSPATH. * Falls back to en_US when the locale has no package of its own, as * WP-CLI does. * * @param string $version WordPress version. * @param string $locale The install's package locale. * @return array{status:string,files:array} */ public static function core( string $version, string $locale ): array { $version = Units::clean_version( $version ); if ( '' === $version ) { return array( 'status' => self::ERROR, 'files' => array(), ); } $locale = 1 === preg_match( '/^[A-Za-z]{2,3}(_[A-Za-z0-9]{2,8})*$/', $locale ) ? $locale : 'en_US'; $locales = 'en_US' === $locale ? array( 'en_US' ) : array( $locale, 'en_US' ); $result = array( 'status' => self::ERROR, 'files' => array(), ); foreach ( $locales as $candidate ) { $body = self::get( add_query_arg( array( 'version' => rawurlencode( $version ), 'locale' => rawurlencode( $candidate ), ), self::CORE_URL ) ); if ( null === $body ) { return $result; } $decoded = json_decode( $body, true ); if ( ! is_array( $decoded ) || ! array_key_exists( 'checksums', $decoded ) ) { return $result; } // `checksums: false` is the API's "no such version/locale". if ( is_array( $decoded['checksums'] ) ) { // Either the flat map asked for, or (without a version) a map of maps. $map = isset( $decoded['checksums'][ $version ] ) && is_array( $decoded['checksums'][ $version ] ) ? $decoded['checksums'][ $version ] : $decoded['checksums']; return array( 'status' => self::FOUND, 'files' => self::files( $map ), ); } $result['status'] = self::NONE; } return $result; } /** * A wordpress.org plugin's checksums for a version, keyed by path * relative to the plugin's directory. * * @param string $slug The plugin's directory name. * @param string $version Its version. * @return array{status:string,files:array} */ public static function plugin( string $slug, string $version ): array { $none = array( 'status' => self::NONE, 'files' => array(), ); $version = Units::clean_version( $version ); if ( '' === $version || 1 !== preg_match( '/^[a-z0-9][a-z0-9_\-]{0,99}$/', $slug ) ) { return $none; // Not a wordpress.org slug: nothing to ask. } $miss_key = $slug . '@' . $version; $misses = self::misses(); if ( isset( $misses[ $miss_key ] ) ) { return $none; } $status = 0; $body = self::get( self::PLUGIN_URL . rawurlencode( $slug ) . '/' . rawurlencode( $version ) . '.json', $status ); if ( 404 === $status ) { self::remember_miss( $miss_key ); return $none; } $decoded = null === $body ? null : json_decode( $body, true ); if ( ! is_array( $decoded ) || ! isset( $decoded['files'] ) || ! is_array( $decoded['files'] ) ) { return array( 'status' => self::ERROR, 'files' => array(), ); } $map = array(); foreach ( $decoded['files'] as $path => $hashes ) { $map[ $path ] = is_array( $hashes ) && array_key_exists( 'md5', $hashes ) ? $hashes['md5'] : null; } return array( 'status' => self::FOUND, 'files' => self::files( $map ), ); } /** * Validate a path => md5-or-list map. * * @param array $map Decoded map. * @return array Path => stored expected list. */ private static function files( array $map ): array { $files = array(); foreach ( $map as $path => $value ) { if ( ! is_string( $path ) || ! Comparison::is_safe_relative( $path ) ) { continue; } $expected = Comparison::expected_from( $value ); if ( null !== $expected ) { $files[ $path ] = $expected; } if ( count( $files ) >= self::MAX_FILES ) { break; } } return $files; } /** * GET a URL; the body on HTTP 200, otherwise null. * * @param string $url HTTPS URL. * @param int $status Set to the HTTP status (0 on a transport error). */ private static function get( string $url, int &$status = 0 ): ?string { $response = wp_safe_remote_get( $url, array( 'timeout' => self::TIMEOUT, 'redirection' => 2, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { $status = 0; return null; } $status = (int) wp_remote_retrieve_response_code( $response ); return 200 === $status ? (string) wp_remote_retrieve_body( $response ) : null; } /** * @return array */ private static function misses(): array { $misses = get_option( self::MISSES_OPTION, array() ); return is_array( $misses ) ? $misses : array(); } /** * @param string $key slug@version. */ private static function remember_miss( string $key ): void { $misses = self::misses(); $misses[ $key ] = 1; if ( count( $misses ) > self::MAX_MISSES ) { $misses = array_slice( $misses, -self::MAX_MISSES, null, true ); } update_option( self::MISSES_OPTION, $misses, false ); } }