From c441b0d528d9f474d9e3a8281343a62e16f92ce3 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Tue, 13 Oct 2026 13:53:14 +0200 Subject: [PATCH 01/12] =?UTF-8?q?Th=C3=A8mes=20:=20constats=20sur=20le=20c?= =?UTF-8?q?=C5=93ur=20de=20WordPress=20fig=C3=A9s=20par=20un=20test=20(fil?= =?UTF-8?q?tre=20update=5Fthemes,=20hook=5Fextra,=20upgrader=5Fpre=5Fdownl?= =?UTF-8?q?oad),=20recontr=C3=B4l=C3=A9s=20en=206.1.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recontrôle 6.1.7 : filtre update_themes_{$hostname} (update.php l.793), 'theme' => $theme dans upgrade() et bulk_upgrade() (l.331 et 446, sans temp_backup), upgrader_pre_download avec $hook_extra (l.270) : identique à 7.1.3. --- tests/Unit/WordPressThemeContractTest.php | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/Unit/WordPressThemeContractTest.php diff --git a/tests/Unit/WordPressThemeContractTest.php b/tests/Unit/WordPressThemeContractTest.php new file mode 100644 index 0000000..837a8bc --- /dev/null +++ b/tests/Unit/WordPressThemeContractTest.php @@ -0,0 +1,78 @@ +open($zip)); + $content = (string) $archive->getFromName('wordpress/' . $path); + $archive->close(); + self::assertNotSame('', $content, $path); + + return str_replace("\r", '', $content); + } + + public function testUpdateThemesFilterSignatureAndResultHandling(): void + { + $update = self::source('wp-includes/update.php'); + self::assertStringContainsString('$update = apply_filters( "update_themes_{$hostname}", false, $theme_data, $theme_stylesheet, $locales );', $update); + self::assertStringContainsString('if ( ! isset( $update->version ) ) {', $update); + self::assertStringContainsString('$update->new_version = $update->version;', $update); + self::assertStringContainsString('$new_update->response[ $theme_stylesheet ] = (array) $update;', $update); + self::assertStringContainsString('$new_update->no_update[ $theme_stylesheet ] = (array) $update;', $update); + self::assertSame(1, preg_match('/\'UpdateURI\'\s+=>\s*\$theme->get\( \'UpdateURI\' \),/', $update)); + } + + public function testThemeUpgradesCarryTheStylesheetInHookExtra(): void + { + $upgrader = self::source('wp-admin/includes/class-theme-upgrader.php'); + // upgrade() (écran Thèmes sans AJAX, mises à jour automatiques) et bulk_upgrade() (Mises à jour, AJAX, WP-CLI). + self::assertSame(2, preg_match_all('/\'hook_extra\'\s*=>\s*array\(\s*\'theme\'\s*=>\s*\$theme,/', $upgrader)); + // Téléversement d'un zip : pas de clé 'theme', paquet local. + self::assertSame(1, preg_match('/\'hook_extra\'\s*=>\s*array\(\s*\'type\'\s*=>\s*\'theme\',\s*\'action\'\s*=>\s*\'install\',\s*\)/', $upgrader)); + self::assertSame(1, preg_match('/\'overwrite_package\'\s*=>\s*false,\s*\/\/ Do not overwrite files\./', $upgrader)); + } + + public function testPreDownloadFilterRunsBeforeTheLocalFileShortcut(): void + { + $base = self::source('wp-admin/includes/class-wp-upgrader.php'); + $filter = strpos($base, "\$reply = apply_filters( 'upgrader_pre_download', false, \$package, \$this, \$hook_extra );"); + $local = strpos($base, "if ( ! preg_match( '!^(http|https|ftp)://!i', \$package ) && file_exists( \$package ) ) { // Local file or remote?"); + self::assertIsInt($filter); + self::assertIsInt($local); + self::assertLessThan($local, $filter); + } + + public function testAutomaticUpdatesUpgradeTheThemeByItsStylesheet(): void + { + $auto = self::source('wp-admin/includes/class-wp-automatic-updater.php'); + self::assertStringContainsString('$upgrader_item = $item->theme;', $auto); + self::assertStringContainsString('$upgrade_result = $upgrader->upgrade(', $auto); + } + + public function testFrozenPluginClientRefusesEveryServerDownloadUrlOutsideAPluginUpdate(): void + { + // Raison de Config::PACKAGE_MARKER dans wawp-theme-updater. + $plugin = str_replace("\r", '', (string) file_get_contents(dirname(__DIR__, 2) . '/wawp-updater/src/class-updater.php')); + self::assertStringContainsString("0 !== strpos( \$package, Config::ENDPOINT . '/v1/download/' )", $plugin); + self::assertStringContainsString("return new \WP_Error( 'wawp_unknown_package'", $plugin); + self::assertStringContainsString("add_filter( 'upgrader_pre_download', array( \$this, 'filter_pre_download' ), 1, 4 );", $plugin); + } +} From 83ade9440e7af3649f1a427ac58e5e57db60ce71 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Tue, 13 Oct 2026 13:58:07 +0200 Subject: [PATCH 02/12] =?UTF-8?q?wawp-theme-updater=20:=20configuration,?= =?UTF-8?q?=20copies=20contr=C3=B4l=C3=A9es=20de=20la=20signature=20et=20d?= =?UTF-8?q?u=20validateur,=20validation=20de=20la=20cl=C3=A9=20themes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpcs.xml.dist | 6 +- phpstan.neon.dist | 1 + tests/Support/Http.php | 20 ++ tests/Unit/SharedCodeTest.php | 48 +++++ tests/Unit/ThemeValidatorTest.php | 96 +++++++++ tests/bootstrap.php | 7 + wawp-theme-updater/src/class-config.php | 55 +++++ wawp-theme-updater/src/class-signature.php | 48 +++++ .../src/class-theme-validator.php | 79 +++++++ wawp-theme-updater/src/class-validator.php | 203 ++++++++++++++++++ 10 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/SharedCodeTest.php create mode 100644 tests/Unit/ThemeValidatorTest.php create mode 100644 wawp-theme-updater/src/class-config.php create mode 100644 wawp-theme-updater/src/class-signature.php create mode 100644 wawp-theme-updater/src/class-theme-validator.php create mode 100644 wawp-theme-updater/src/class-validator.php diff --git a/phpcs.xml.dist b/phpcs.xml.dist index aa3a56f..a31fa46 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,7 +1,8 @@ - Standard WordPress et compatibilité PHP 7.4 pour la bibliothèque embarquée. + Standard WordPress et compatibilité PHP 7.4 pour les bibliothèques embarquées. wawp-updater + wawp-theme-updater @@ -12,6 +13,7 @@ + @@ -20,6 +22,8 @@ + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bac3a86..07e9325 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,6 +5,7 @@ parameters: phpVersion: 70400 paths: - wawp-updater + - wawp-theme-updater - scripts - bin bootstrapFiles: diff --git a/tests/Support/Http.php b/tests/Support/Http.php index d9a8391..24af99a 100644 --- a/tests/Support/Http.php +++ b/tests/Support/Http.php @@ -31,4 +31,24 @@ public static function offer(string $version, string $signature, string $token = 'signature' => $signature, ]; } + + /** @param array> $offers */ + public static function theme_body(array $offers): string + { + return (string) json_encode(['themes' => (object) $offers], JSON_UNESCAPED_SLASHES); + } + + /** @return array Offre de thème au format de /v1/check (spec thèmes 5.2). */ + public static function theme_offer(string $slug, string $version, string $signature, string $token = 'abc.def'): array + { + return [ + 'theme' => $slug, + 'new_version' => $version, + 'requires' => '6.1', + 'requires_php' => '7.4', + 'url' => 'https://www.wearewp.pro/', + 'package' => 'https://updates.wearewp.pro/v1/download/' . $token, + 'signature' => $signature, + ]; + } } diff --git a/tests/Unit/SharedCodeTest.php b/tests/Unit/SharedCodeTest.php new file mode 100644 index 0000000..3713544 --- /dev/null +++ b/tests/Unit/SharedCodeTest.php @@ -0,0 +1,48 @@ + */ + public static function sharedFiles(): array + { + return [ + 'signature' => ['src/class-signature.php'], + 'validateur' => ['src/class-validator.php'], + ]; + } + + #[DataProvider('sharedFiles')] + public function testThemeCopyIsIdenticalToThePluginOneExceptTheNamespace(string $file): void + { + $root = dirname(__DIR__, 2); + $plugin = (string) file_get_contents("$root/wawp-updater/$file"); + $theme = (string) file_get_contents("$root/wawp-theme-updater/$file"); + self::assertStringContainsString("\nnamespace WAWP\ThemeUpdater;\n", $theme); + self::assertStringNotContainsString('WAWP\Updater', $theme); + self::assertSame($plugin, str_replace('WAWP\ThemeUpdater', 'WAWP\Updater', $theme)); + } + + public function testSameServerAndSameSigningKeys(): void + { + self::assertSame(\WAWP\Updater\Config::PUBLIC_KEYS, \WAWP\ThemeUpdater\Config::PUBLIC_KEYS); + self::assertSame(\WAWP\Updater\Config::ENDPOINT, \WAWP\ThemeUpdater\Config::ENDPOINT); + self::assertSame(\WAWP\Updater\Config::HOST, \WAWP\ThemeUpdater\Config::HOST); + } + + public function testThemeMarkerIsOutsideThePrefixInterceptedByThePluginClient(): void + { + self::assertStringStartsNotWith(\WAWP\Updater\Config::ENDPOINT . '/v1/download/', \WAWP\ThemeUpdater\Config::PACKAGE_MARKER); + self::assertStringStartsWith(\WAWP\ThemeUpdater\Config::ENDPOINT . '/', \WAWP\ThemeUpdater\Config::PACKAGE_MARKER); + } +} diff --git a/tests/Unit/ThemeValidatorTest.php b/tests/Unit/ThemeValidatorTest.php new file mode 100644 index 0000000..92fd94f --- /dev/null +++ b/tests/Unit/ThemeValidatorTest.php @@ -0,0 +1,96 @@ + $changes */ + private static function body(array $changes = []): string + { + return Http::theme_body([self::SLUG => $changes + Http::theme_offer(self::SLUG, '1.4.0', self::signature())]); + } + + public function testConfigValues(): void + { + self::assertSame('1.0.0', Config::VERSION); + self::assertSame('updates.wearewp.pro', Config::HOST); + self::assertSame('https://updates.wearewp.pro', Config::ENDPOINT); + self::assertSame('https://updates.wearewp.pro/v1/theme-update/', Config::PACKAGE_MARKER); + self::assertSame('wawp_theme_updater_check', Config::CACHE_KEY); + self::assertNotSame(\WAWP\Updater\Config::CACHE_KEY, Config::CACHE_KEY); + self::assertSame(43200, Config::CACHE_TTL); + self::assertSame(3600, Config::ERROR_TTL); + self::assertSame(10, Config::TIMEOUT); + self::assertSame(300, Config::DOWNLOAD_TIMEOUT); + } + + public function testValidCheckResponse(): void + { + self::assertSame( + [self::SLUG => [ + 'version' => '1.4.0', + 'requires' => '6.1', + 'requires_php' => '7.4', + 'url' => 'https://www.wearewp.pro/', + 'package' => 'https://updates.wearewp.pro/v1/download/abc.def', + 'signature' => self::signature(), + ]], + Theme_Validator::check_response(self::body(), [self::SLUG]) + ); + } + + public function testEmptyListsEmptyUrlAndNullRequirementsAreAccepted(): void + { + self::assertSame([], Theme_Validator::check_response('{"themes":{}}', [self::SLUG])); + self::assertSame([], Theme_Validator::check_response('{"themes":[]}', [self::SLUG])); + $offers = Theme_Validator::check_response(self::body(['url' => '', 'requires' => null, 'requires_php' => null]), [self::SLUG]); + self::assertNotNull($offers); + self::assertSame('', $offers[self::SLUG]['url']); + self::assertNull($offers[self::SLUG]['requires']); + self::assertNull($offers[self::SLUG]['requires_php']); + } + + public function testAnyAnomalyRejectsTheWholeResponse(): void + { + $good = Http::theme_offer(self::SLUG, '1.4.0', self::signature()); + $cases = [ + 'pas du JSON' => 'nope', + 'réponse de plugins' => '{"plugins":{}}', + 'thème non demandé' => Http::theme_body(['theme-intrus' => ['theme' => 'theme-intrus'] + $good]), + 'champ theme différent de la clé' => self::body(['theme' => 'autre-theme']), + 'champ theme absent' => Http::theme_body([self::SLUG => array_diff_key($good, ['theme' => true])]), + 'version invalide' => self::body(['new_version' => '1.4.0-beta']), + 'version absente' => Http::theme_body([self::SLUG => array_diff_key($good, ['new_version' => true])]), + 'version numérique' => self::body(['new_version' => 1.4]), + 'hôte de paquet étranger' => self::body(['package' => 'https://evil.example/v1/download/abc']), + 'préfixe trompeur' => self::body(['package' => 'https://updates.wearewp.pro.evil.example/v1/download/abc']), + 'chemin de paquet détourné' => self::body(['package' => 'https://updates.wearewp.pro/v1/download/../x']), + 'marqueur au lieu d\'un jeton' => self::body(['package' => 'https://updates.wearewp.pro/v1/theme-update/wawp-demo-theme']), + 'signature trop courte' => self::body(['signature' => base64_encode('court')]), + 'requires invalide' => self::body(['requires' => 'latest']), + 'url javascript' => self::body(['url' => 'javascript:alert(1)']), + 'url avec espace' => self::body(['url' => 'https://www.wearewp.pro/a b']), + 'url avec chevron' => self::body(['url' => 'https://www.wearewp.pro/