Files
nolimit-discounts/includes/class-updater.php
T
kristianwindandClaude Opus 5 b5b4bc2e03 Answer only for our own plugin, not every plugin on the host
🔴 Live-shop bug, found by the subscriptions session with a screenshot.

`update_plugins_{$hostname}` is keyed on the HOSTNAME, not the repository.
Every plugin updating from gitea.nolimit.dk shares the single hook
`update_plugins_gitea.nolimit.dk`, and WordPress runs every callback on it for
every one of those plugins:

    $update = apply_filters( "update_plugins_{$hostname}", false,
                             $plugin_data, $plugin_file, $locales );

This updater registered with `10, 2` — so it never received $plugin_file — and
returned its own release unconditionally. Result on a live shop: a subscriptions
plugin was shown this plugin's 1.4.4 and told it was an update for itself.

Load order decides which callback wins, so the direction it breaks in is
arbitrary: a rename, a different folder name, a plugins_loaded priority change,
and it flips. If it flips this way round, a plugin with auto-updates enabled
installs somebody else's zip over itself, unattended, on a shop taking money.

Both halves are the fix. `10, 3` so the argument arrives, and a check that the
file WordPress is asking about is ours. With the count left at 2 the guard
would compare against an empty string and silently do nothing — the same shape
of bug it exists to fix.

tests/smoke.php now calls offer_update() with a foreign plugin file and
requires false back. The site test installs exactly one plugin from this host,
so the collision cannot arise there on its own: that assertion is the defence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:54:08 +02:00

403 lines
14 KiB
PHP

<?php
/**
* Updates from the plugin's own release feed.
*
* WordPress only offers updates for plugins it knows a source for. This is
* that source: the Gitea repository named in the plugin header's "Update URI".
*
* It hooks `update_plugins_{$hostname}` rather than rewriting the update
* transient by hand. That filter is the supported path since WordPress 5.8 and
* it brings one guarantee worth more than the tidiness: a plugin with an
* "Update URI" pointing away from wordpress.org is never asked about there, so
* a .org plugin that happens to share this slug can never be pushed onto a
* shop running this one.
*
* Nothing about the site is sent. The request is a plain unauthenticated GET
* for a file every visitor to that repository can read, and the user agent is
* overridden because WordPress's default one carries the site's own URL in it.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Offers releases from the repository named in the plugin header.
*/
class WC_EDM_Updater {
/**
* Where the answer is cached between checks.
*/
const TRANSIENT = 'wc_edm_release_feed';
/**
* How long that cache lives.
*
* Six hours, originally, on the reasoning that WordPress checks twice a
* day anyway. That reasoning was wrong, and the symptom was a shop that
* had just been told a new version existed and was shown nothing.
*
* WordPress does not check twice a day when somebody is looking. It gates
* its own check by screen — one minute on Dashboard → Updates, one hour on
* the Plugins screen, twelve hours in the background (wp_update_plugins(),
* switch on current_filter()). This filter only runs when that check runs,
* so WordPress is already the rate limiter, and a cache longer than its
* cadence does nothing but hide new releases underneath it.
*
* Fifteen minutes is left only to absorb a burst — several calls in one
* request, or other code calling wp_update_plugins() with extra stats,
* which bypasses the gate above.
*/
const TTL = 15 * MINUTE_IN_SECONDS;
/**
* Hook into the update check for our own host only.
*/
public function __construct() {
$host = $this->host();
if ( ! $host ) {
return;
}
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
// Without this the old answer is served for up to six hours after an
// update, and the plugins screen keeps offering a version already
// installed.
add_action( 'upgrader_process_complete', array( $this, 'forget' ), 10, 0 );
// And "Check again" has to actually check again.
add_action( 'admin_init', array( $this, 'forget_on_force_check' ) );
// "View version details" next to the update notice.
add_filter( 'plugins_api', array( $this, 'plugin_details' ), 10, 3 );
}
/**
* Memoised update URI, so the plugin file is read at most once a request.
*
* @var string|null
*/
private static $uri = null;
/**
* The repository this plugin updates from, as stated in its own header.
*
* Read from the header rather than a constant so that there is exactly one
* place saying where updates come from — but memoised, because the filter
* is registered on every request a shop serves, not just in wp-admin, and
* get_file_data() opens and scans the file each time it is asked.
*
* @return string
*/
public static function update_uri() {
if ( null === self::$uri ) {
$data = get_file_data( WC_EDM_PLUGIN_FILE, array( 'uri' => 'Update URI' ) );
self::$uri = isset( $data['uri'] ) ? trim( $data['uri'] ) : '';
}
return self::$uri;
}
/**
* Hostname of the update URI, or an empty string when there is none.
*
* @return string
*/
private function host() {
$host = wp_parse_url( self::update_uri(), PHP_URL_HOST );
return is_string( $host ) ? $host : '';
}
/**
* Answer WordPress's update check for this plugin.
*
* Always returns what the feed says, even when it is the version already
* installed: WordPress compares the versions itself, and a plugin that
* answers "no update" by returning false never appears in the no_update
* list — which is what the "Enable auto-updates" link on the plugins
* screen is built from. Staying silent here is how a plugin ends up
* unable to auto-update while looking perfectly configured.
*
* @param array|false $update Update offered so far.
* @param array $plugin_data Headers of the installed plugin.
* @param string $plugin_file Which plugin WordPress is asking about.
* @return array|false
*/
public function offer_update( $update, $plugin_data, $plugin_file = '' ) {
// The filter name is built from the HOSTNAME, not the repository:
// every plugin that updates from gitea.nolimit.dk shares the one hook
// `update_plugins_gitea.nolimit.dk`, and WordPress runs every callback
// on it for every one of those plugins. Without this check a plugin
// answers for its neighbour and offers it its own release — measured
// on a live shop, where a subscriptions plugin was offered this one's
// 1.4.4 and told it was an update for itself.
//
// The `10, 3` on the add_filter above is half of this fix. With the
// accepted-argument count left at 2 the third argument never arrives,
// $plugin_file stays empty, and the guard silently does nothing — the
// same shape of bug it exists to fix.
if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) {
return $update;
}
$release = $this->latest_release();
if ( ! $release ) {
return $update;
}
return array(
'slug' => dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ),
'version' => $release['version'],
'package' => $release['package'],
'url' => $release['url'],
'tested' => $release['tested'],
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
// Without this, Dashboard → Updates shows the generic grey plug
// next to the plugin. WordPress takes the first of svg, 2x, 1x,
// default that is set.
//
// Served from the shop's own copy of the plugin rather than from
// the release host: it is one fewer thing that has to be up and
// reachable for wp-admin to render correctly, it cannot go mixed
// content on an HTTPS shop, and the icon is in the zip anyway.
'icons' => array(
'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
'default' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
),
);
}
/**
* Answer the "View version details" modal.
*
* WordPress builds that link as
* plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
* plugins_api() — and plugins_api() asks **wordpress.org**. This plugin is
* not there, so without this filter the link opens a modal containing an
* error, on the same screen that is otherwise telling the shop owner an
* update is ready. Nothing warns you: the link looks like every other
* plugin's.
*
* @param false|object|array $result Response so far.
* @param string $action Requested action.
* @param object $args Request arguments.
* @return false|object|array
*/
public function plugin_details( $result, $action, $args ) {
$slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) );
if ( 'plugin_information' !== $action || empty( $args->slug ) || $args->slug !== $slug ) {
return $result;
}
require_once ABSPATH . 'wp-admin/includes/plugin.php';
$plugin = get_plugin_data( WC_EDM_PLUGIN_FILE, false, false );
$release = $this->latest_release();
$notes = $release && '' !== $release['notes'] ? $release['notes'] : '';
return (object) array(
'name' => $plugin['Name'],
'slug' => $slug,
'version' => $release ? $release['version'] : $plugin['Version'],
'author' => $plugin['Author'],
'author_profile'=> $plugin['AuthorURI'],
'homepage' => $plugin['PluginURI'],
'requires' => $plugin['RequiresWP'],
'requires_php' => $plugin['RequiresPHP'],
'tested' => $release ? $release['tested'] : '',
'download_link' => $release ? $release['package'] : '',
'icons' => array( 'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg' ),
'sections' => array(
'description' => wpautop( esc_html( $plugin['Description'] ) ),
// Plain text from the release notes, not markdown: rendering it
// as HTML would mean trusting whatever is in a release body.
'changelog' => '' !== $notes
? wpautop( esc_html( $notes ) )
: wpautop( esc_html__( 'Release notes are published with each release.', 'wc-email-discount' ) ),
),
);
}
/**
* Drop the cached feed.
*/
public function forget() {
delete_transient( self::TRANSIENT );
}
/**
* Honour the "Check again" link on Dashboard → Updates.
*
* The six-hour cache is right for the check WordPress runs twice a day on
* its own, and wrong for a button whose entire promise is that it looks
* right now. Without this, someone who has just published a release clicks
* it, sees nothing, and concludes the update mechanism is broken.
*
* No nonce to verify: this reads a link WordPress itself renders, and the
* only effect is discarding a cache — the worst a forged request achieves
* is one extra HTTP GET.
*/
public function forget_on_force_check() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['force-check'] ) || ! current_user_can( 'update_plugins' ) ) {
return;
}
$this->forget();
}
/**
* Read the newest release from the repository.
*
* @return array|null { version, package, url, tested }, or null.
*/
private function latest_release() {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return $cached;
}
if ( 'none' === $cached ) {
// A failure is cached too, briefly. Otherwise an unreachable host
// turns every admin page load into a blocking HTTP request.
return null;
}
$uri = self::update_uri();
$api = $this->api_url( $uri );
if ( ! $api ) {
return null;
}
$response = wp_remote_get(
$api,
array(
'timeout' => 10,
'headers' => array( 'Accept' => 'application/json' ),
// WordPress's default user agent is
// "WordPress/6.8; https://the-shop.example" — it would tell
// the update host which shops are running this plugin. This
// plugin does not report that anywhere, so it must not leak
// it here either.
'user-agent' => 'wc-email-discount-manager updater',
)
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
return null;
}
$release = $this->parse_release( json_decode( wp_remote_retrieve_body( $response ), true ) );
if ( ! $release ) {
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
return null;
}
set_transient( self::TRANSIENT, $release, self::TTL );
return $release;
}
/**
* Turn a release API response into what WordPress needs.
*
* The download address is rebuilt from the repository address in the
* plugin header rather than taken from the response's
* browser_download_url. That field is built from the forge's own
* configured root URL, which is not necessarily the address the outside
* world reaches it on — gitea.nolimit.dk returns
* "http://57.129.89.117:25001/..." there, a plain-HTTP raw IP that
* bypasses the Cloudflare tunnel in front of it. A shop handed that would
* be downloading plugin code over an unencrypted connection to an address
* that is free to change. Measured on the v1.4.0 release.
*
* @param mixed $body Decoded API response.
* @return array|null { version, package, url, tested }, or null.
*/
private function parse_release( $body ) {
if ( ! is_array( $body ) || empty( $body['tag_name'] ) ) {
return null;
}
$tag = $body['tag_name'];
$base = rtrim( self::update_uri(), '/' );
$zip = '';
foreach ( isset( $body['assets'] ) ? $body['assets'] : array() as $asset ) {
if ( ! empty( $asset['name'] ) && '.zip' === substr( $asset['name'], -4 ) ) {
$zip = $asset['name'];
}
}
if ( '' === $zip ) {
// A release with no zip attached is a half-finished release.
// Offering it would hand WordPress a download it cannot install.
return null;
}
// The release notes carry the compatibility line the release workflow
// writes, so wp-admin can show what this version was actually tested
// against instead of what the previous one claimed.
$tested = '';
if ( ! empty( $body['body'] ) && preg_match( '/^Tested up to:\s*(\S+)/mi', $body['body'], $m ) ) {
$tested = $m[1];
}
return array(
'version' => ltrim( $tag, 'vV' ),
'package' => $base . '/releases/download/' . rawurlencode( $tag ) . '/' . rawurlencode( $zip ),
'url' => $base . '/releases/tag/' . rawurlencode( $tag ),
'tested' => $tested,
// The release notes are the changelog. Kept so the "View version
// details" modal has something true to show.
'notes' => isset( $body['body'] ) ? (string) $body['body'] : '',
);
}
/**
* Turn the repository's web address into its "latest release" API address.
*
* https://gitea.example/owner/repo
* -> https://gitea.example/api/v1/repos/owner/repo/releases/latest
*
* Derived rather than configured, so the plugin header stays the single
* place that says where updates come from.
*
* @param string $uri Repository web address.
* @return string Empty when the address is not a repository address.
*/
private function api_url( $uri ) {
$parts = wp_parse_url( $uri );
if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) || empty( $parts['path'] ) ) {
return '';
}
$segments = array_values( array_filter( explode( '/', $parts['path'] ) ) );
if ( count( $segments ) < 2 ) {
return '';
}
return sprintf(
'%s://%s/api/v1/repos/%s/%s/releases/latest',
$parts['scheme'],
$parts['host'],
rawurlencode( $segments[0] ),
rawurlencode( $segments[1] )
);
}
}