12: Conflict resolver - #12
Open
nikolaystrikhar wants to merge 3 commits into
Open
Conversation
Conflict resolution deactivates a plugin and ends the request, and it ran at plugins_loaded on every request type with no gate. Known issue B logged this as a logged-out visitor bouncing to the login screen, which is the mildest case. The same path turns a visitor's checkout POST into a 302 that silently drops the order, bounces a login POST back to a blank form, aborts wp-cron before its event loop, and ends a WP-CLI command with status 0 and no output, since header() does nothing under the CLI SAPI. Now gated on is_admin() plus not cron, AJAX or WP-CLI -- is_admin() alone is not enough, because admin-ajax.php and admin-post.php both define WP_ADMIN. deactivate_plugins() is now silent and passes no $network_wide. Verified against core: the default is null, not false, and core takes the network branch on 'false !== $network_wide' and the blog branch on 'true !== $network_wide', so null covers both. The infinite-redirect justification for computing the flag never existed, and passing true skips the blog branch, stranding an entry that needs a second request and a second deactivation hook to clear. Silent because the standalone's own deactivation callback would otherwise run at plugins_loaded, where a routine flush_rewrite_rules() rebuilds the rules before any post type is registered and every custom permalink 404s. Core's automatic deactivations are silent for the same reason. redirect_destination() matches the screen rather than a substring of an absolute URL. wp_get_referer() prefers the _wp_http_referer field that every nonce-bearing admin form carries, and that holds a bare path -- so the 'never interrupt an inline update' guard missed every admin form POST, the whole network admin, and any site behind a TLS-terminating proxy. The shared resolve() test helper caught the halt exception without asserting it arrived, so four tests passed whether or not the redirect happened at all. Two network-flag tests now run against real core instead of a stub, because that claim is the only thing the argument rested on.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What:
Conflict\Resolver— detect an active standalone and act on the policy — plusLoader::resolver()and theplugins_loaded@1 hook, ahead of the @2 load loop.Stacked on #11.
Why this way: resolution runs before the load loop because a standalone that wins the conflict has already defined the guard constant, and the load loop reads it. An unrecognised policy is normalised to
NOTICE_ONLYrather than falling through to the destructive default.This is the most destructive code in the library — it deactivates a plugin someone deliberately turned on, then ends the request. The review found four defects, and three of them contradict reasoning written into the plan and spec. All four were verified against WordPress core rather than argued.
It ran on every request type, with no gate. The spec logged this as known issue B and deferred it to 1.0.1, described as "bouncing a logged-out visitor to the login screen". That is the mildest case, not the representative one. The same path turns a visitor's checkout POST into a 302 that silently drops the order (the browser follows with a GET; the body is gone), bounces a
wp-login.phpPOST back to a blank form so the user thinks their password is wrong, abortswp-cron.phpbefore its event loop, and ends a WP-CLI command with status 0 and no output —header()is a no-op under the CLI SAPI. Losing an order to pre-empt a conflict that cannot cause a front-end fatal is not a trade worth deferring, so this is fixed rather than deferred. Note the spec's proposed fix was also insufficient:is_admin()alone does not exclude AJAX, becauseadmin-ajax.phpandadmin-post.phpboth defineWP_ADMIN.To be clear about what this was and wasn't: not a privilege-escalation or CSRF bug. The deactivation target comes from the host's registered config, never from the request, and
wp_get_referer()already runswp_validate_redirect(), so there is no open redirect. It was an availability and data-loss bug that fired for whoever happened to make the first request.The
$network_wideargument's justification is factually wrong. The plan says omitting it makesdeactivate_plugins()"a silent no-op for a network-activated plugin, so the next request would deactivate nothing and redirect again, forever." Verified against core: the default isnull, notfalse. Core enters the network branch onfalse !== $network_wideand the blog branch ontrue !== $network_wide— sonulltakes both. The infinite loop never existed. And passing a computedtrueis strictly worse: it skips the blog branch, so a plugin that is both network-active and listed in a blog'sactive_pluginskeeps that entry and needs a second request, and a second deactivation hook firing, to clear. The argument is dropped. Two tests now run against real core instead of a stub, because that claim was the only thing holding the argument up.The deactivation was not silent. With
$silent = false, core firesdeactivate_pluginand then the standalone's ownregister_deactivation_hook()callback — atplugins_loaded, beforeinit. Aflush_rewrite_rules()in that callback, which is boilerplate in a large fraction of plugins, rebuilds the rules with no post type or taxonomy registered yet: every custom permalink on the site 404s until something flushes again. Core makes exactly this distinction — its interactive admin paths passfalse, its automatic ones (validate_active_plugins(), the plugin upgrader) passtrue. This is automatic, so it is silent now.The "never interrupt an inline update" guard never fired. It compared the referrer against
admin_url(...)withstrpos(). Butwp_get_referer()prefers$_REQUEST['_wp_http_referer'], which every nonce-bearing admin form carries, and that field holds a bare path —/wp-admin/plugins.php, not the absolute URL. So a plugins.php bulk action POST missed the guard and got redirected, silently dropping the bulk action: precisely what the docblock said must not happen. It also missed the entire network admin (/wp-admin/network/plugins.phpdoes not contain/wp-admin/plugins.php) and any site behind a TLS-terminating proxy whereadmin_url()says http and the referrer says https. It now parses the URL and matches the screen filename.One test problem worth naming: the shared
resolve()helper caught the halt exception without asserting it arrived — the exact silent-pass failuretests/README.mdopens by warning about. Four tests passed whether or not the redirect happened at all. The helper takes an explicit expectation now, and the non-redirecting paths assert the halt did not occur.Also fixed en route: the plan's Task 12 test code used
$thisandself::inside uopz stub closures, which is a fatal —tests/README.mddocuments the reference-binding pattern, and the plan predates it.Verify:
slic run unit— 185 tests, 304 assertions, green (1 multisite-only skip).slic run unit --env multisite— green.composer test:analysis→[OK] No errors, exit 0.Not covered: convergence across requests with two conflicting standalones. Traced by hand —
deactivate()ends the request, so the second is handled on the next one, andis_standalone_plugin_active()is false for the first by then. It converges in N+1 requests rather than looping, but nothing proves it.