{"id":18891678,"date":"2026-04-27T07:56:02","date_gmt":"2026-04-27T07:56:02","guid":{"rendered":"https:\/\/wordpress.org\/support\/topic\/statuserrcode101\/"},"modified":"2026-04-27T07:56:02","modified_gmt":"2026-04-27T07:56:02","slug":"statuserrcode101","status":"publish","type":"topic","link":"https:\/\/wordpress.org\/support\/topic\/statuserrcode101\/","title":{"rendered":"status=ERR&amp;code=101"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><code>http_headers_option()<\/code> hooked into global <code>added_option<\/code>\/<code>updated_option<\/code> redirects users away from <code>wp-admin\/update-core.php<\/code> (and other admin pages) with <code>?status=ERR&amp;code=101<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Plugin:<\/strong> HTTP Headers (<code>http-headers<\/code>)<br \/><strong>Plugin version:<\/strong> 1.19.4<br \/><strong>WordPress:<\/strong> tested on 6.9.x (FSE)<br \/><strong>PHP:<\/strong> 8.2+<br \/><strong>Site type:<\/strong> single-site Summary<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>http_headers_option()<\/code> is bound to the <strong>global<\/strong> WordPress hooks <code>added_option<\/code> and <code>updated_option<\/code> (<code>http-headers.php:1678\u20131679<\/code>). Those hooks fire on every option write in <code>wp_options<\/code>, including options that have nothing to do with this plugin \u2014 most notably the update-related site transients (<code>_site_transient_update_core<\/code>, <code>_site_transient_update_plugins<\/code>, <code>_site_transient_update_themes<\/code>, <code>_site_transient_update_translations<\/code>).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The handler then runs an <code>option_page<\/code>-based nonce check (<code>http-headers.php:837\u2013844<\/code>) that has no chance of passing on writes that do not originate from one of the plugin&#8217;s own settings forms. The check fails, the handler calls <code>wp_safe_redirect(... 'options-general.php?page=http-headers&amp;tab=advanced&amp;status=ERR&amp;code=101'); exit;<\/code>, and the original request is killed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The most user-visible symptom is that <strong><code>wp-admin\/update-core.php<\/code> becomes unreachable<\/strong> in certain situations: every time it is loaded, WordPress refreshes the update site-transients via <code>update_option()<\/code>, the plugin&#8217;s hook fires, and the page silently redirects to the HTTP Headers settings screen with <code>code=101<\/code>. Reproduction (deterministic)<\/p>\n\n\n\n<ol>\n<li>Single-site WordPress install with HTTP Headers 1.19.4 active.<\/li>\n\n\n\n<li>Run any plugin \/ theme \/ core update so the update site-transients are invalidated. (Equivalent: hit <code>update-core.php?force-check=1<\/code> once to refresh, then wait until the transients expire \u2014 the second case is harder to time, the post-update case is reliable.)<\/li>\n\n\n\n<li>As an administrator, navigate to <code>wp-admin\/update-core.php<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong> the Updates screen renders.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Actual:<\/strong> the browser is redirected to <code>wp-admin\/options-general.php?page=http-headers&amp;tab=advanced&amp;status=ERR&amp;code=101<\/code> and the Updates screen is never shown. Other admin pages mostly continue to work, because they do not happen to write <code>wp_options<\/code> rows in their own request lifecycle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Deactivating the plugin and re-activating it appears to &#8220;fix&#8221; the symptom, but only because the just-failed request itself wrote the transients before the redirect, so the next visit to <code>update-core.php<\/code> finds them within their TTL and skips the refresh \u2014 the underlying bug is unchanged and will resurface as soon as the transients expire or are force-refreshed. Root cause<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>http-headers.php:1674\u20131679<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>if ( is_admin() ){\n    add_action('admin_menu', 'http_headers_admin_add_page');\n    add_action('admin_init', 'http_headers_admin');\n    add_filter('pre_update_option', 'http_headers_pre_update_option', 10, 3);\n    add_action('added_option', 'http_headers_option');\n    add_action('updated_option', 'http_headers_option');\n    ...\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>added_option<\/code> and <code>updated_option<\/code> are core WordPress hooks that fire for <strong>every<\/strong> option write \u2014 they are not restricted to options owned by this plugin. They also fire for site-transient writes on single-site installs, because <code>set_site_transient()<\/code> ultimately calls <code>update_option('_site_transient_&lt;name&gt;', ...)<\/code> when the install is not multisite.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>http-headers.php:831\u2013844<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>function http_headers_option($option) {\n\n    include_once ABSPATH . 'wp-admin\/includes\/admin.php';\n    require_once ABSPATH . WPINC . '\/pluggable.php';\n\n    $action = '-options';\n    if (isset($_POST&#091;'option_page'])) {\n        $action = sanitize_text_field(wp_unslash($_POST&#091;'option_page'])) . '-options';\n    }\n    if (!isset($_POST&#091;'_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST&#091;'_wpnonce'])), $action)) {\n        wp_safe_redirect(sprintf(\"%soptions-general.php?page=http-headers&amp;tab=advanced&amp;status=ERR&amp;code=101\", get_admin_url()));\n        exit;\n    }\n    ...\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For any option write that does <strong>not<\/strong> originate from one of the plugin&#8217;s own settings forms \u2014 i.e. for the vast majority of option writes in any WordPress install \u2014 <code>$_POST['_wpnonce']<\/code> is either entirely absent (GET requests, REST\/AJAX, WP-Cron, programmatic <code>update_option()<\/code> calls from core or other plugins) or belongs to a completely unrelated nonce action. Either way, <code>wp_verify_nonce(..., '&lt;option_page&gt;-options')<\/code> returns false, and the handler unconditionally calls <code>wp_safe_redirect(...); exit;<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>update-core.php<\/code> case is just the most visible manifestation, because that screen reliably triggers <code>update_option('_site_transient_update_*', ...)<\/code> calls on every load when the transients have expired or been invalidated \u2014 which is exactly the state they are in immediately after any update. Why this design is problematic in general<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Even setting aside <code>update-core.php<\/code>, hooking <code>added_option<\/code> \/ <code>updated_option<\/code> and then issuing a redirect from inside the handler has wider consequences:<\/p>\n\n\n\n<ul>\n<li>WP-Cron, REST endpoints, AJAX callbacks and CLI runs that touch <code>wp_options<\/code> (extremely common \u2014 caches, transients, scheduled tasks, third-party plugins) all hit the same code path. They have no <code>$_POST['_wpnonce']<\/code> and therefore all fail the check. In an HTTP context the <code>wp_safe_redirect(); exit;<\/code> aborts the request mid-flight; in CLI\/cron contexts the <code>exit<\/code> truncates the run. None of these contexts are the ones the nonce check is trying to protect against.<\/li>\n\n\n\n<li>The Settings API has already validated the nonce <em>before<\/em> <code>update_option()<\/code> is reached on legitimate settings-page submissions. A second verification <em>after<\/em> the write \u2014 and crucially, on <em>every<\/em> unrelated write too \u2014 adds no defensive value but causes the breakage described above.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Suggested fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;s options are namespaced with the <code>hh_<\/code> prefix:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code> function http_headers_option($option) {\n\n+    \/\/ Bail for option writes that don't belong to this plugin.\n+    \/\/ <code>added_option<\/code> \/ <code>updated_option<\/code> are global hooks and fire for\n+    \/\/ every wp_options write (including site transients on single-site\n+    \/\/ installs, e.g. _site_transient_update_core \/ _update_plugins \/\n+    \/\/ _update_themes refreshed by wp-admin\/update-core.php).\n+    if (strpos($option, 'hh_') !== 0) {\n+        return;\n+    }\n+\n     include_once ABSPATH . 'wp-admin\/includes\/admin.php';\n     require_once ABSPATH . WPINC . '\/pluggable.php';\n\n     $action = '-options';\n     ...\n }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Better long-term: drop the post-write nonce check entirely and rely on the Settings API&#8217;s own pre-write nonce verification (<code>options.php<\/code> already validates <code>&lt;option_page&gt;-options<\/code> before any registered option is written). Any post-write work that is genuinely needed (htaccess rebuild, etc.) should be gated on <code>$option<\/code> belonging to this plugin and on the appropriate user capability via <code>current_user_can()<\/code> \u2014 not on inspecting <code>$_POST['_wpnonce']<\/code> from inside a global hook.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Additionally, the <code>wp_safe_redirect(); exit;<\/code> 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. <code>admin_init<\/code> for the plugin&#8217;s settings pages), not in <code>updated_option<\/code>. Self-contained repro snippet<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On a single-site install with the plugin active, log in as admin and load:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/wp-admin\/update-core.php?force-check=1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The browser ends up at:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/wp-admin\/options-general.php?page=http-headers&amp;tab=advanced&amp;status=ERR&amp;code=101<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Replacing the hook bodies with the diff above \u2014 even without removing the hooks \u2014 restores normal navigation without affecting any of the plugin&#8217;s own settings flows.<\/p>\n","protected":false},"template":"","class_list":["post-18891678","topic","type-topic","status-publish","hentry"],"jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/wordpress.org\/support\/wp-json\/wp\/v2\/topic\/18891678","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wordpress.org\/support\/wp-json\/wp\/v2\/topic"}],"about":[{"href":"https:\/\/wordpress.org\/support\/wp-json\/wp\/v2\/types\/topic"}],"version-history":[{"count":0,"href":"https:\/\/wordpress.org\/support\/wp-json\/wp\/v2\/topic\/18891678\/revisions"}],"wp:attachment":[{"href":"https:\/\/wordpress.org\/support\/wp-json\/wp\/v2\/media?parent=18891678"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}