Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"wpautop",
"wpdb",
"WPMU",
"wpnonce",
"wpunit"
],
"ignoreWords": [
Expand Down
30 changes: 21 additions & 9 deletions docs/conflict-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,27 @@ example of this behavior.
The guard cannot help on the request that *activates* the standalone: WordPress includes the plugin
being activated **after** the bundled copy has already loaded, so that re-declaration is a real
fatal. Core catches it in its activation sandbox and prints *"Plugin could not be activated because
it triggered a fatal error."* — true, and useless to whoever pressed the button.

So the library filters `wp_admin_notice_markup` and swaps that sentence for the sub-plugin's
`conflict_notice_message`, falling back to a generic one naming the slug. This is what puts the
WordPress floor at 6.4: the filter does not exist before it.

It touches nothing else. The markup comes back untouched unless every one of these holds — the
screen is `plugins`, or `plugins-network` in the network admin; the `plugin` query arg names a
standalone this library has registered; and `_error_nonce` verifies.
it triggered a fatal error."* — true, and useless to whoever pressed the button. Directly under that
sentence it embeds an iframe that runs the activation a second time with errors on display, so the
raw `Cannot redeclare …` prints inside the same notice box, contradicting whatever explanation sits
above it.

So the library filters `wp_admin_notice_markup`, swaps that sentence for the sub-plugin's
`conflict_notice_message` — falling back to a generic one naming the slug — and takes the sandbox
iframe out with it. This is what puts the WordPress floor at 6.4: the filter does not exist before
it.

**The same conflict has a second screen.** A fatal in the activation sandbox leaves the standalone
*paused*, so it returns to the plugins list with a **Resume** link, and pressing that fails
identically — worded *"Plugin could not be resumed because it triggered a fatal error."* Both
sentences are rewritten.

It touches nothing else. The markup comes back exactly as it arrived unless every one of these holds
— the screen is `plugins`, or `plugins-network` in the network admin; `_error_nonce` verifies for a
standalone this library has registered, named by the `plugin` query arg on the activation screen and
by the nonce alone on the resume one, which carries no plugin name; and one of core's two sentences
is still there to replace. A notice failing any of them keeps its iframe as well: where this library
has nothing to say, core's diagnostic is the only thing that does.

The replacement runs through `wp_kses_post()`, so a knowledge-base link survives, and a message that
filters down to nothing leaves core's wording in place rather than blanking the notice. A host that
Expand Down
211 changes: 178 additions & 33 deletions src/Conflict/Rewriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
* useless to whoever pressed the button. All this library gets to do about it is reword that one
* sentence, and the wording is the sub-plugin's own `conflict_notice_message`.
*
* Rewording is not the whole of that screen, though. Core appends an `error_scrape` iframe to the
* sentence, and the iframe re-runs the activation sandbox with `display_errors` forced on — so the
* raw `Cannot redeclare …` fatal prints directly beneath the friendly explanation unless the iframe
* comes out with the sentence.
*
* Its own class rather than a method on the notice writer, though the message is shared with the
* merge notice. Nothing here is stored, drawn or queued: it reads the request, checks the screen,
* verifies a nonce and edits markup core wrote. The writer would have needed a registry to find out
Expand Down Expand Up @@ -54,8 +59,9 @@ public function __construct( Reader $registry ) {
/**
* The markup to print in place of the one WordPress was about to.
*
* Handed back untouched unless the request really is a nonce-verified activation error, on a
* plugins screen, for a standalone this library has registered.
* Handed back untouched unless the request really is a nonce-verified activation or resume
* error, on a plugins screen, for a standalone this library has registered — and unless one of
* the two sentences core reports a plugin fatal with is still there to swap out.
*
* @since 1.0.0
*
Expand All @@ -81,43 +87,21 @@ public function rewrite( string $markup ): string {
return $markup;
}

// phpcs:disable WordPress.Security.NonceVerification.Recommended -- verified below, once
// the plugin named turns out to be one this library owns. Nothing is acted on until then.
$basename = isset( $_GET['plugin'] ) ? wp_unslash( $_GET['plugin'] ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- this read *is* the nonce.
$nonce = isset( $_GET['_error_nonce'] )
? sanitize_text_field( wp_unslash( $_GET['_error_nonce'] ) )
: '';

// Unslashed and no further. Core mints the activation-error nonce from
// wp_unslash( $_REQUEST['plugin'] ) verbatim (wp-admin/plugins.php), so sanitizing here
// would verify an action core never signed: a plugin whose folder name holds a '%xx'
// sequence, a '<' or a leading space comes back changed from sanitize_text_field(), and
// both the nonce check and the registry lookup below would then miss -- silently, and on
// the one screen this class exists to improve. Nothing sanitizing would remove is needed
// here either: the value is compared against a basename the host configured and hashed into
// a nonce action, and never reaches the page. What does reach it is the sub-plugin's message.
//
// is_string() because sanitize_text_field() was doing that job: '?plugin[]=x' arrives as an
// array, and an array reaching wp_verify_nonce() is a string conversion, not a refusal.
if ( ! is_string( $basename ) || $basename === '' ) {
if ( $nonce === '' ) {
return $markup;
}

// Looked up before the nonce is checked, deliberately: there is no nonce work to do for a
// plugin this library does not own, and the nonce is still verified before any markup is
// touched.
$sub_plugin = $this->find_by_standalone_basename( $basename );
$sub_plugin = $this->find_by_activation_error( $nonce ) ?? $this->find_by_resume_error( $nonce );

if ( $sub_plugin === null ) {
return $markup;
}

$nonce = isset( $_GET['_error_nonce'] )
? sanitize_text_field( wp_unslash( $_GET['_error_nonce'] ) )
: '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended

if ( ! wp_verify_nonce( $nonce, 'plugin-activation-error_' . $basename ) ) {
return $markup;
}

$message = $sub_plugin->get_conflict_notice_message(
sprintf(
'%s is bundled with this plugin and loads automatically. The standalone copy cannot'
Expand All @@ -137,10 +121,139 @@ public function rewrite( string $markup ): string {
return $markup;
}

// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose.
$core_text = __( 'Plugin could not be activated because it triggered a <strong>fatal error</strong>.', 'default' );
foreach ( $this->sentences_core_reports_a_fatal_with() as $sentence ) {
if ( strpos( $markup, $sentence ) === false ) {
continue;
}

return $this->without_the_error_scrape( str_replace( $sentence, $message, $markup ) );
}

// Neither sentence is in there, so this notice was authored by something other than the
// screen this class knows how to improve. The iframe stays with it: a notice nobody
// explained is bad, and a notice nobody explained with its one diagnostic quietly deleted
// is worse.
return $markup;
}

/**
* The two sentences core reports a sandboxed plugin fatal with, exactly as it prints them.
*
* Read back through `__()` against core's own text domain rather than written out as literals,
* so the swap still happens on a site running WordPress in another language: `wp_admin_notice()`
* receives the translated sentence, and an English needle would match nothing there.
*
* The resume wording belongs beside the activation one because the conflict outlives the
* activation screen. Core's fatal-error handler pauses the plugin its sandbox died in, so the
* standalone comes back on the plugins list with a Resume link, and pressing that re-runs the
* same sandbox into the same re-declaration — reported just as uselessly, in a different verb.
*
* @since 1.0.0
*
* @return string[]
*/
private function sentences_core_reports_a_fatal_with(): array {
return [
// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose.
__( 'Plugin could not be activated because it triggered a <strong>fatal error</strong>.', 'default' ),
// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose.
__( 'Plugin could not be resumed because it triggered a <strong>fatal error</strong>.', 'default' ),
];
}

/**
* The registered sub-plugin an activation-error request names, if the nonce agrees.
*
* The `plugin` argument is unslashed and no further. Core mints the activation-error nonce from
* `wp_unslash( $_REQUEST['plugin'] )` verbatim (wp-admin/plugins.php), so sanitizing here would
* verify an action core never signed: a plugin whose folder name holds a '%xx' sequence, a '<'
* or a leading space comes back changed from sanitize_text_field(), and both the nonce check and
* the registry lookup would then miss -- silently, and on the one screen this class exists to
* improve. Nothing sanitizing would remove is needed here either: the value is compared against
* a basename the host configured and hashed into a nonce action, and never reaches the page.
* What does reach it is the sub-plugin's message.
*
* is_string() because sanitize_text_field() was doing that job: '?plugin[]=x' arrives as an
* array, and an array reaching wp_verify_nonce() is a string conversion, not a refusal.
*
* The registry is read before the nonce is verified, deliberately: there is no nonce work to do
* for a plugin this library does not own, and the nonce is still verified before any markup is
* touched.
*
* @since 1.0.0
*
* @param string $nonce The `_error_nonce` this request carries.
*
* @return Sub_Plugin|null
*/
private function find_by_activation_error( string $nonce ): ?Sub_Plugin {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified below, once the
// plugin named turns out to be one this library owns. Nothing is acted on until then.
$basename = isset( $_GET['plugin'] ) ? wp_unslash( $_GET['plugin'] ) : '';

if ( ! is_string( $basename ) || $basename === '' ) {
return null;
}

$sub_plugin = $this->find_by_standalone_basename( $basename );

if ( $sub_plugin === null ) {
return null;
}

return wp_verify_nonce( $nonce, 'plugin-activation-error_' . $basename ) ? $sub_plugin : null;
}

/**
* The registered sub-plugin a resume-error request is about, if the nonce agrees.
*
* Identified from the nonce alone, because core's resume redirect carries no `plugin` argument
* to read: `resume_plugin()` appends `_error_nonce` and nothing else, minted from
* `plugin-resume-error_` and the basename. The nonce is therefore the only thing on the request
* that names a plugin, so every registered standalone is offered to it in turn and the one it
* was signed for answers. A forged value still matches nothing -- being unable to name the
* plugin does not make the check weaker, only the loop longer.
*
* Behind `error=resuming`, which is not a second guess at the same thing. Without it the loop
* ran on every plugins-screen request carrying an `_error_nonce` the activation lookup missed --
* an ordinary activation failure for somebody else's plugin -- and each miss fires core's
* `wp_verify_nonce_failed`, which security plugins hook to count and rate-limit failed nonces. A
* site with five sub-plugins produced five of them on a screen this library has nothing to do
* with. The gate costs nothing, because it is the request core itself dispatches the resume
* wording on: `resume_plugin()` is only ever handed `plugins.php?error=resuming`, and
* `wp-admin/plugins.php` words the notice off exactly that value.
*
* A sub-plugin with no standalone configured is skipped rather than tested against an action
* that ends in nothing, which is a question about a plugin that does not exist.
*
* @since 1.0.0
*
* @param string $nonce The `_error_nonce` this request carries.
*
* @return Sub_Plugin|null
*/
private function find_by_resume_error( string $nonce ): ?Sub_Plugin {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- which screen this is, not
// an act to authorise; the nonce below is what authorises.
$error = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : '';

if ( $error !== 'resuming' ) {
return null;
}

foreach ( $this->registry->all() as $sub_plugin ) {
if ( ! $sub_plugin->has_standalone_plugin() ) {
continue;
}

$action = 'plugin-resume-error_' . $sub_plugin->get_standalone_plugin_basename();

return str_replace( $core_text, $message, $markup );
if ( wp_verify_nonce( $nonce, $action ) ) {
return $sub_plugin;
}
}

return null;
}

/**
Expand All @@ -166,4 +279,36 @@ private function find_by_standalone_basename( string $basename ): ?Sub_Plugin {

return null;
}

/**
* The same notice with core's activation-sandbox iframe taken out of it.
*
* Core appends `<iframe … src="…plugins.php?action=error_scrape&…">` to the sentence just
* replaced (wp-admin/plugins.php). That request runs `plugin_sandbox_scrape()` again with
* `display_errors` forced on, so the raw `Cannot redeclare …` fatal prints inside the notice,
* directly under the explanation — and rewording alone leaves the owner reading both, the second
* one contradicting the first.
*
* Matched on `error_scrape` within the opening tag rather than on the whole element core built.
* Rebuilding that string would mean reproducing `add_query_arg()`, `urlencode()` and `esc_url()`
* over a URL assembled from `admin_url()`, and a filter on any of them makes the removal miss
* without saying so. `[^>]*` cannot run past the end of the tag, and `action=error_scrape` is
* the one request in wp-admin that re-runs the fatal — so an iframe some other plugin appended,
* and every other thing core put in this notice, is out of the pattern's reach.
*
* @since 1.0.0
*
* @param string $markup Notice markup whose sentence has already been rewritten.
*
* @return string
*/
private function without_the_error_scrape( string $markup ): string {
$stripped = preg_replace( '#<iframe\b[^>]*\berror_scrape\b[^>]*>\s*</iframe>#i', '', $markup );

// Null means the match itself failed -- a backtrack or recursion limit on a notice far larger
// than the one core writes. The pattern carries no `u` modifier, so PCRE runs on bytes here
// and whatever encoding the substituted message arrived in cannot fail it. The reworded
// sentence is worth keeping on its own.
return is_string( $stripped ) ? $stripped : $markup;
}
}
15 changes: 10 additions & 5 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -875,10 +875,15 @@ load guard cannot prevent: the owner reinstalls the standalone and presses
Activate, WordPress includes it on top of the bundled copy, and the
re-declaration is a real fatal that core's sandbox reports as "the plugin
triggered a fatal error" — true, and useless. All the library gets to do is
reword the sentence. Driven through core's own filter dispatch rather than by
calling the rewriter, because the admin-only `add_filter()` is half of what has
to work. The notice box stays core's — its classes, its dismiss button, its
wrapper; only the sentence inside is ours.
reword the sentence — either of core's two, since the same box carries "could
not be resumed" out of recovery mode — and take core's `error_scrape` iframe
down with it, which would otherwise re-include the standalone in a sandbox and
print the re-declaration fatal under the message that just explained it away.
Driven through core's own filter dispatch rather than by calling the rewriter,
because the admin-only `add_filter()` is half of what has to work, and off a
fixture core's own `wp_get_admin_notice()` assembled, iframe included — an
invented notice would leave the removal proven by nothing. The notice box stays
core's: its id, its classes, its wrapper; only the sentence inside is ours.

```mermaid
sequenceDiagram
Expand All @@ -892,7 +897,7 @@ sequenceDiagram
WP->>WP: redirects to plugins.php with plugin and _error_nonce
WP->>Rw: wp_admin_notice_markup filter
Rw->>Rw: screen is plugins, arg names a registered standalone, nonce verifies
Rw-->>WP: core's sentence swapped for the host's, wrapper untouched
Rw-->>WP: core's sentence swapped for the host's, error_scrape iframe removed, wrapper untouched
```

#### `Scenario/HostTest.php` — the host's own wiring
Expand Down
Loading
Loading