Skip to content

12: Conflict resolver - #12

Open
nikolaystrikhar wants to merge 3 commits into
11-loader-load-pathfrom
12-conflict-resolver
Open

12: Conflict resolver#12
nikolaystrikhar wants to merge 3 commits into
11-loader-load-pathfrom
12-conflict-resolver

Conversation

@nikolaystrikhar

Copy link
Copy Markdown
Contributor

What: Conflict\Resolver — detect an active standalone and act on the policy — plus Loader::resolver() and the plugins_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_ONLY rather 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.php POST back to a blank form so the user thinks their password is wrong, aborts wp-cron.php before 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, because admin-ajax.php and admin-post.php both define WP_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 runs wp_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_wide argument's justification is factually wrong. The plan says omitting it makes deactivate_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 is null, not false. Core enters the network branch on false !== $network_wide and the blog branch on true !== $network_wide — so null takes both. The infinite loop never existed. And passing a computed true is strictly worse: it skips the blog branch, so a plugin that is both network-active and listed in a blog's active_plugins keeps 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 fires deactivate_plugin and then the standalone's own register_deactivation_hook() callback — at plugins_loaded, before init. A flush_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 pass false, its automatic ones (validate_active_plugins(), the plugin upgrader) pass true. This is automatic, so it is silent now.

The "never interrupt an inline update" guard never fired. It compared the referrer against admin_url(...) with strpos(). But wp_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.php does not contain /wp-admin/plugins.php) and any site behind a TLS-terminating proxy where admin_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 failure tests/README.md opens 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 $this and self:: inside uopz stub closures, which is a fatal — tests/README.md documents 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, and is_standalone_plugin_active() is false for the first by then. It converges in N+1 requests rather than looping, but nothing proves it.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant