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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Wpcomsh fatal-error: route the screen's "Enter recovery mode" link through a first-party redirect endpoint that logs a `wpcomsh_fatal_recovery` event before forwarding to a freshly-generated core recovery URL, so we can measure screen-originated recovery clicks alongside the existing signature and deactivate events without conflating them with email-originated entries.
1 change: 1 addition & 0 deletions projects/plugins/wpcomsh/wpcom-fatal-error/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ path to deactivate the offending plugin.
| `fatal-error-helpers.php` | Pure helpers: viewer detection, plugin identification, signed-form/recovery URL builders. Testable in isolation. |
| `fatal-error-screen.css` | Styles, inlined into the page at render time. |
| `fatal-plugin-deactivator.php` | Early-running endpoint that validates the signed deactivation POST, persists the change, and redirects. |
| `fatal-recovery-redirect.php` | Early-running endpoint behind the screen's "Enter recovery mode" link: logs `wpcomsh_fatal_recovery` and 302s to a fresh core recovery URL. |

## Architecture notes

Expand Down
331 changes: 265 additions & 66 deletions projects/plugins/wpcomsh/wpcom-fatal-error/fatal-error-helpers.php

Large diffs are not rendered by default.

16 changes: 6 additions & 10 deletions projects/plugins/wpcomsh/wpcom-fatal-error/fatal-error-screen.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,7 @@ function wpcomsh_customize_fatal_error_message( $message, $error = array() ) { /
wpcomsh_fatal_log_event( $plugin, 'wpcomsh_fatal_signature' );
} elseif ( ! empty( $error['file'] ) ) {
$coarse_key = 'wpcomsh_fatal_file:' . hash( 'sha256', (string) $error['file'] );
$do_log = true;

try {
$do_log = wp_cache_add( $coarse_key, 1, 'wpcomsh', HOUR_IN_SECONDS );
} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- fail open: a cache failure should not silence telemetry.
// Fail open.
}

if ( $do_log ) {
if ( wpcomsh_fatal_dedup_acquire( $coarse_key, HOUR_IN_SECONDS ) ) {
wpcomsh_fatal_log_event( wpcomsh_fatal_identify_plugin( $error ), 'wpcomsh_fatal_signature' );
}
}
Expand Down Expand Up @@ -117,7 +109,11 @@ function wpcomsh_fatal_build_render_context( $error, $plugin = null, $user_id =
'plugin' => $plugin,
'error_message' => $is_admin ? (string) ( $error['message'] ?? '' ) : '',
'deactivate_form' => $can_deactivate ? wpcomsh_fatal_build_deactivate_form( $plugin['basename'] ) : null,
'recovery_url' => $can_recover ? wpcomsh_fatal_build_recovery_url() : '',
// Endpoint-mediated link so the recovery key is minted on click
// (one row in the `recovery_keys` option per click, not per
// render) and we can log the click. Helper gates multisite. See
// fatal-recovery-redirect.php for the auth model.
'recovery_url' => $can_recover ? wpcomsh_fatal_build_recovery_redirect_url( $user_id ) : '',
'support_url' => 'https://wordpress.com/help/contact',
'environment' => $is_admin ? wpcomsh_fatal_get_environment_lines() : array(),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,54 +30,24 @@
* @return void
*/
function wpcomsh_fatal_maybe_deactivate_plugin() {
// Nonces aren't usable in this endpoint (pluggable.php hasn't loaded yet);
// we validate an HMAC signature below instead. The early-return check only
// reads the parameter *presence* — actual values are validated after.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
// Nonces aren't usable here (pluggable.php hasn't loaded yet); the HMAC
// check below is the actual auth gate. The presence check + regex below
// only constrain the inputs before they're trusted.
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( empty( $_POST['wpcomsh_deactivate'] ) || empty( $_POST['wpcomsh_sig'] ) || empty( $_POST['wpcomsh_exp'] ) ) {
return;
}
if ( ! defined( 'AUTH_SALT' ) ) {
return;
}
// Cookie constants (LOGGED_IN_COOKIE etc.) are defined later in wp-settings.php
// — between muplugins_loaded and active_plugins iteration. At mu-plugin load
// time they don't exist yet, but wp_cookie_constants() is already available.
if ( ! defined( 'LOGGED_IN_COOKIE' ) && function_exists( 'wp_cookie_constants' ) ) {
wp_cookie_constants();
}
if ( ! defined( 'LOGGED_IN_COOKIE' ) ) {
return;
}

// Nonces aren't usable here because pluggable.php hasn't loaded yet — we
// validate an HMAC signature below instead. Inputs are constrained by
// regex / cast to int before being trusted.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$plugin = isset( $_POST['wpcomsh_deactivate'] ) ? sanitize_text_field( wp_unslash( $_POST['wpcomsh_deactivate'] ) ) : '';
$sig = isset( $_POST['wpcomsh_sig'] ) ? sanitize_text_field( wp_unslash( $_POST['wpcomsh_sig'] ) ) : '';
$exp = isset( $_POST['wpcomsh_exp'] ) ? (int) $_POST['wpcomsh_exp'] : 0;
$plugin = sanitize_text_field( wp_unslash( $_POST['wpcomsh_deactivate'] ) );
$sig = sanitize_text_field( wp_unslash( $_POST['wpcomsh_sig'] ) );
$exp = (int) $_POST['wpcomsh_exp'];
// phpcs:enable WordPress.Security.NonceVerification.Missing

// Reject expired or malformed plugin paths (no traversal; slug/file.php only).
if ( $exp < time() ) {
return;
}
// Reject malformed plugin paths (no traversal; slug/file.php only).
if ( ! preg_match( '#^[a-zA-Z0-9][a-zA-Z0-9_.-]*/[a-zA-Z0-9][a-zA-Z0-9_.-]*\.php$#', $plugin ) ) {
return;
}

// The cookie is used only as a per-session secret inside an HMAC we
// never output; sanitization would destroy the byte-for-byte match.
$cookie_value = isset( $_COOKIE[ LOGGED_IN_COOKIE ] )
? (string) wp_unslash( $_COOKIE[ LOGGED_IN_COOKIE ] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
: '';
if ( '' === $cookie_value ) {
return;
}

$expected = hash_hmac( 'sha256', $plugin . '|' . $exp . '|' . $cookie_value, (string) AUTH_SALT );
if ( ! hash_equals( $expected, $sig ) ) {
if ( ! wpcomsh_fatal_verify_payload( $plugin, $exp, $sig ) ) {
return;
}

Expand Down
165 changes: 165 additions & 0 deletions projects/plugins/wpcomsh/wpcom-fatal-error/fatal-recovery-redirect.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php
/**
* One-shot redirect endpoint behind the fatal-error screen's
* "Enter recovery mode" link.
*
* The screen points its recovery link here
* (`?wpcomsh_recover=1&_wpnonce=…` on `site_url()`) rather than at the
* bare core recovery URL so we can:
* - log a `wpcomsh_fatal_recovery` event keyed on the actual click,
* not on every fatal-screen pageview;
* - mint the core recovery key on click rather than at render time,
* keeping the `recovery_keys` option from accumulating a row per
* pageview.
*
* Auth: WP nonce + cookie-resolved current user + `resume_plugins` /
* `resume_themes` capability. The nonce binds the URL to the admin's
* session, so a CSRF-style navigation can't reach the endpoint
* without the rendered nonce. The deactivator endpoint keeps its
* custom HMAC because it runs before pluggable.php loads, where
* `wp_verify_nonce` isn't available.
*
* Failure paths 302 back to the same URL with the recovery query args
* stripped, instead of letting WP serve a normal page response under
* `?wpcomsh_recover=1&…`. Two reasons: the user-visible URL stops
* carrying the suspicious params (so a refresh or back-nav doesn't
* re-trigger the endpoint), and any side-effecting bootstraps below
* (the pluggable.php load via `wpcomsh_fatal_current_user_id`) land on
* a header-only 302 response — never a rendered page where a regular
* plugin's pluggable override would silently no-op because pluggable
* was already loaded at mu-plugin time.
*
* Load-order argument mirrors fatal-plugin-deactivator.php.
*
* @package wpcomsh
*/

/**
* Validate the click, dedup the log, mint a fresh core recovery URL,
* and 302 to it. On any failure after we recognize this as a recovery
* click, 302 to the same URL minus the recovery query args.
*
* @return void
*/
function wpcomsh_fatal_maybe_handle_recovery_click() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce verified by wp_verify_nonce() below.
if ( empty( $_GET['wpcomsh_recover'] ) ) {
return; // Not our request — let WP serve normally.
}

// Raw header() rather than wp_redirect(): the early bails (empty
// nonce, multisite) run before pluggable.php is loaded, and pulling
// pluggable in just to bounce defeats the point of containing the
// preload damage.
//
// Derive the redirect base from `site_url('/')` rather than echoing
// $_SERVER['REQUEST_URI']: a crafted protocol-relative request URI
// like `//attacker.com/path` would otherwise produce a `Location:
// //attacker.com/path` header that browsers follow cross-origin (open
// redirect). Routing through site_url also lands the user at the WP
// install root on subdirectory installs (e.g. example.com/wp/)
// instead of the domain root. Try/catch covers a misbehaving
// `site_url` filter from an earlier mu-plugin throwing.
//
// Only the query string is carried over from the request — read from
// $_SERVER['QUERY_STRING'] (no host can be smuggled in there), with
// the recovery args stripped via `remove_query_arg` (which accepts a
// query-only string and re-encodes via WP's standard `build_query`).
$bail_clean = static function (): never {
$base_path = '/';
try {
$path = wp_parse_url( site_url( '/' ), PHP_URL_PATH );
if ( is_string( $path ) && '' !== $path ) {
$base_path = $path;
}
} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- fall back to '/' on filter throw.
// Fall through.
}

// `remove_query_arg` accepts a query-only string and re-encodes the
// remaining args through WP's `build_query`, so the raw bytes from
// QUERY_STRING are safe to pass through — text-field sanitization
// would mangle valid query syntax (`+`, etc.) for no benefit.
$query = remove_query_arg(
array( 'wpcomsh_recover', '_wpnonce' ),
(string) wp_unslash( $_SERVER['QUERY_STRING'] ?? '' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see comment above; remove_query_arg parses + re-encodes.
);
$location = $base_path;
if ( '' !== $query ) {
$location .= '?' . $query;
}
header( 'Location: ' . $location, true, 302 );
exit;
};

if ( empty( $_GET['_wpnonce'] ) ) {
$bail_clean();
}
$nonce = sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended

// The screen never emits this URL on multisite, so any matching request
// here is bogus.
if ( is_multisite() ) {
$bail_clean();
}

// wpcomsh_fatal_current_user_id() bootstraps pluggable.php (needed by
// wp_verify_nonce / wp_redirect / the recovery-link service below)
// and validates the auth cookie without setting `$current_user`. We
// then call wp_set_current_user ourselves so wp_verify_nonce uses
// our resolved id instead of re-validating the cookie, and pin the
// `nonce_life` filter so the verify-time tick agrees with the
// mint-time tick across load phases. The resolve+verify cluster is
// wrapped in try/catch because the `set_current_user` action can be
// hooked by another mu-plugin loaded earlier in this pass — a throw
// must not turn the recovery-link click into a second fatal.
$user_id = wpcomsh_fatal_current_user_id();
if ( ! $user_id ) {
$bail_clean();
}
try {
wp_set_current_user( $user_id );
wpcomsh_fatal_pin_recover_nonce_life();
if ( ! wp_verify_nonce( $nonce, 'wpcomsh_recover' ) ) {
$bail_clean();
}
} catch ( \Throwable $e ) {
$bail_clean();
}

// `current_user_can` rather than `user_can( $user_id, ... )`: we just
// called `wp_set_current_user`, so `$current_user` is populated and
// the cap check skips a redundant `get_userdata()` lookup.
if ( ! current_user_can( 'resume_plugins' ) && ! current_user_can( 'resume_themes' ) ) {
$bail_clean();
}

$recovery_url = wpcomsh_fatal_build_recovery_url();
if ( '' === $recovery_url ) {
$bail_clean();
}

// Dedup gates the *log only*, not the redirect: a refresh / back-nav
// shouldn't flood log rows, but the user must still reach recovery
// mode — otherwise the link silently looks broken. (Core recovery
// keys are single-use, so each click mints a fresh one.) Per-user
// keying is enough because the cap check above already constrains
// callers to admins on this site.
if ( wpcomsh_fatal_dedup_acquire( 'wpcomsh_fatal_event:recovery:' . $user_id ) ) {
wpcomsh_fatal_emit_logstash_event( 'wpcomsh_fatal_recovery' );
}

// `wp_redirect()` rather than `wp_safe_redirect()`: on split-host installs
// (home_url() and wp_login_url() on different hosts) wp_safe_redirect()
// would `wp_validate_redirect()` against allowed_redirect_hosts, which at
// mu-plugin time hasn't been extended by plugins yet — the login host
// gets rejected and the user is silently bounced to admin_url() without
// the recovery cookie, landing right back on the fatal screen. The URL
// is core-generated for *this* site (no user input), so the open-redirect
// risk wp_safe_redirect() guards against doesn't apply.
wp_redirect( $recovery_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- see comment above; wp_safe_redirect() drops split-host login URLs at mu-plugin time.
exit;
}

wpcomsh_fatal_maybe_handle_recovery_click();
4 changes: 4 additions & 0 deletions projects/plugins/wpcomsh/wpcom-fatal-error/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
* the admin notification copy.
* fatal-plugin-deactivator.php Early-running endpoint that honors the
* signed deactivation URL the screen renders.
* fatal-recovery-redirect.php Early-running endpoint behind the screen's
* "Enter recovery mode" link: logs the click
* and 302s to a fresh core recovery URL.
*
* @package wpcomsh
*/
Expand All @@ -22,3 +25,4 @@
require_once __DIR__ . '/fatal-error-screen.php';
require_once __DIR__ . '/fatal-error-email.php';
require_once __DIR__ . '/fatal-plugin-deactivator.php';
require_once __DIR__ . '/fatal-recovery-redirect.php';
Loading