status=ERR&code=101
-
http_headers_option()hooked into globaladded_option/updated_optionredirects users away fromwp-admin/update-core.php(and other admin pages) with?status=ERR&code=101Plugin: HTTP Headers (
http-headers)
Plugin version: 1.19.4
WordPress: tested on 6.9.x (FSE)
PHP: 8.2+
Site type: single-site Summaryhttp_headers_option()is bound to the global WordPress hooksadded_optionandupdated_option(http-headers.php:1678–1679). Those hooks fire on every option write inwp_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 callswp_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.phpbecomes unreachable in certain situations: every time it is loaded, WordPress refreshes the update site-transients viaupdate_option(), the plugin’s hook fires, and the page silently redirects to the HTTP Headers settings screen withcode=101. Reproduction (deterministic)- Single-site WordPress install with HTTP Headers 1.19.4 active.
- Run any plugin / theme / core update so the update site-transients are invalidated. (Equivalent: hit
update-core.php?force-check=1once to refresh, then wait until the transients expire — the second case is harder to time, the post-update case is reliable.) - 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=101and the Updates screen is never shown. Other admin pages mostly continue to work, because they do not happen to writewp_optionsrows 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.phpfinds 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 causehttp-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_optionandupdated_optionare 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, becauseset_site_transient()ultimately callsupdate_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, programmaticupdate_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 callswp_safe_redirect(...); exit;.The
update-core.phpcase is just the most visible manifestation, because that screen reliably triggersupdate_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 generalEven setting aside
update-core.php, hookingadded_option/updated_optionand 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 thewp_safe_redirect(); exit;aborts the request mid-flight; in CLI/cron contexts theexittruncates 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_optionare 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.phpalready validates<option_page>-optionsbefore any registered option is written). Any post-write work that is genuinely needed (htaccess rebuild, etc.) should be gated on$optionbelonging to this plugin and on the appropriate user capability viacurrent_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_initfor the plugin’s settings pages), not inupdated_option. Self-contained repro snippetOn a single-site install with the plugin active, log in as admin and load:
/wp-admin/update-core.php?force-check=1The browser ends up at:
/wp-admin/options-general.php?page=http-headers&tab=advanced&status=ERR&code=101Replacing 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.
You must be logged in to reply to this topic.