• Resolved homepagehelden

    (@homepagehelden)


    http_headers_option() hooked into global added_option/updated_option redirects users away from wp-admin/update-core.php (and other admin pages) with ?status=ERR&code=101

    Plugin: HTTP Headers (http-headers)
    Plugin version: 1.19.4
    WordPress: tested on 6.9.x (FSE)
    PHP: 8.2+
    Site type: single-site Summary

    http_headers_option() is bound to the global WordPress hooks added_option and updated_option (http-headers.php:1678–1679). Those hooks fire on every option write in wp_options, including options that have nothing to do with this plugin — most notably the update-related site transients (_site_transient_update_core, _site_transient_update_plugins, _site_transient_update_themes, _site_transient_update_translations).

    The handler then runs an option_page-based nonce check (http-headers.php:837–844) that has no chance of passing on writes that do not originate from one of the plugin’s own settings forms. The check fails, the handler calls wp_safe_redirect(... 'options-general.php?page=http-headers&tab=advanced&status=ERR&code=101'); exit;, and the original request is killed.

    The most user-visible symptom is that wp-admin/update-core.php becomes unreachable in certain situations: every time it is loaded, WordPress refreshes the update site-transients via update_option(), the plugin’s hook fires, and the page silently redirects to the HTTP Headers settings screen with code=101. Reproduction (deterministic)

    1. Single-site WordPress install with HTTP Headers 1.19.4 active.
    2. Run any plugin / theme / core update so the update site-transients are invalidated. (Equivalent: hit update-core.php?force-check=1 once to refresh, then wait until the transients expire — the second case is harder to time, the post-update case is reliable.)
    3. As an administrator, navigate to wp-admin/update-core.php.

    Expected: the Updates screen renders.

    Actual: the browser is redirected to wp-admin/options-general.php?page=http-headers&tab=advanced&status=ERR&code=101 and the Updates screen is never shown. Other admin pages mostly continue to work, because they do not happen to write wp_options rows in their own request lifecycle.

    Deactivating the plugin and re-activating it appears to “fix” the symptom, but only because the just-failed request itself wrote the transients before the redirect, so the next visit to update-core.php finds them within their TTL and skips the refresh — the underlying bug is unchanged and will resurface as soon as the transients expire or are force-refreshed. Root cause

    http-headers.php:1674–1679:

    if ( is_admin() ){
        add_action('admin_menu', 'http_headers_admin_add_page');
        add_action('admin_init', 'http_headers_admin');
        add_filter('pre_update_option', 'http_headers_pre_update_option', 10, 3);
        add_action('added_option', 'http_headers_option');
        add_action('updated_option', 'http_headers_option');
        ...
    }

    added_option and updated_option are core WordPress hooks that fire for every option write — they are not restricted to options owned by this plugin. They also fire for site-transient writes on single-site installs, because set_site_transient() ultimately calls update_option('_site_transient_<name>', ...) when the install is not multisite.

    http-headers.php:831–844:

    function http_headers_option($option) {
    
        include_once ABSPATH . 'wp-admin/includes/admin.php';
        require_once ABSPATH . WPINC . '/pluggable.php';
    
        $action = '-options';
        if (isset($_POST['option_page'])) {
            $action = sanitize_text_field(wp_unslash($_POST['option_page'])) . '-options';
        }
        if (!isset($_POST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), $action)) {
            wp_safe_redirect(sprintf("%soptions-general.php?page=http-headers&tab=advanced&status=ERR&code=101", get_admin_url()));
            exit;
        }
        ...
    }

    For any option write that does not originate from one of the plugin’s own settings forms — i.e. for the vast majority of option writes in any WordPress install — $_POST['_wpnonce'] is either entirely absent (GET requests, REST/AJAX, WP-Cron, programmatic update_option() calls from core or other plugins) or belongs to a completely unrelated nonce action. Either way, wp_verify_nonce(..., '<option_page>-options') returns false, and the handler unconditionally calls wp_safe_redirect(...); exit;.

    The update-core.php case is just the most visible manifestation, because that screen reliably triggers update_option('_site_transient_update_*', ...) calls on every load when the transients have expired or been invalidated — which is exactly the state they are in immediately after any update. Why this design is problematic in general

    Even setting aside update-core.php, hooking added_option / updated_option and then issuing a redirect from inside the handler has wider consequences:

    • WP-Cron, REST endpoints, AJAX callbacks and CLI runs that touch wp_options (extremely common — caches, transients, scheduled tasks, third-party plugins) all hit the same code path. They have no $_POST['_wpnonce'] and therefore all fail the check. In an HTTP context the wp_safe_redirect(); exit; aborts the request mid-flight; in CLI/cron contexts the exit truncates the run. None of these contexts are the ones the nonce check is trying to protect against.
    • The Settings API has already validated the nonce before update_option() is reached on legitimate settings-page submissions. A second verification after the write — and crucially, on every unrelated write too — adds no defensive value but causes the breakage described above.

    Suggested fix

    At minimum, restrict the handler to options actually owned by this plugin and bail out for everything else, before any of the nonce / redirect logic runs. All of this plugin’s options are namespaced with the hh_ prefix:

     function http_headers_option($option) {
    
    +    // Bail for option writes that don't belong to this plugin.
    +    // added_option / updated_option are global hooks and fire for
    +    // every wp_options write (including site transients on single-site
    +    // installs, e.g. _site_transient_update_core / _update_plugins /
    +    // _update_themes refreshed by wp-admin/update-core.php).
    +    if (strpos($option, 'hh_') !== 0) {
    +        return;
    +    }
    +
         include_once ABSPATH . 'wp-admin/includes/admin.php';
         require_once ABSPATH . WPINC . '/pluggable.php';
    
         $action = '-options';
         ...
     }

    Better long-term: drop the post-write nonce check entirely and rely on the Settings API’s own pre-write nonce verification (options.php already validates <option_page>-options before any registered option is written). Any post-write work that is genuinely needed (htaccess rebuild, etc.) should be gated on $option belonging to this plugin and on the appropriate user capability via current_user_can() — not on inspecting $_POST['_wpnonce'] from inside a global hook.

    Additionally, the wp_safe_redirect(); exit; pattern is unsafe inside option-write hooks regardless of the gate above: those hooks can fire from cron, REST, AJAX and CLI contexts where redirecting is meaningless or harmful. Surfacing errors to the user belongs in the request handler that initiated the form submission (e.g. admin_init for the plugin’s settings pages), not in updated_option. Self-contained repro snippet

    On a single-site install with the plugin active, log in as admin and load:

    /wp-admin/update-core.php?force-check=1

    The browser ends up at:

    /wp-admin/options-general.php?page=http-headers&tab=advanced&status=ERR&code=101

    Replacing the hook bodies with the diff above — even without removing the hooks — restores normal navigation without affecting any of the plugin’s own settings flows.

Viewing 2 replies - 1 through 2 (of 2 total)
Viewing 2 replies - 1 through 2 (of 2 total)

You must be logged in to reply to this topic.