Sitemaps: Don't return a 404 status for valid sitemap requests when the main query has no posts - #13247
Sitemaps: Don't return a 404 status for valid sitemap requests when the main query has no posts#13247i-am-chitti wants to merge 18 commits into
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
… fix/65945-sitemap-404-no-posts
… fix/65945-sitemap-404-no-posts
|
Please note the RC for 7.1.1 is September 10th if we want it included in that version. Otherwise it is fine to punt to 7.1.2. |
westonruter
left a comment
There was a problem hiding this comment.
The tests seem like they need to be updated to listen for calls to wp_die() instead of looking at is_404().
… fix/65945-sitemap-404-no-posts
…into fix/65945-sitemap-404-no-posts
|
Sorry for the back-and-forth on this. I think for 7.1.1 we'll go back to just the theme's 404 template as it is a smaller change with less risk, and then in 7.2 we can improve the error handling with wider testing. I'm going to have Claude share some review feedback that led me to this. |
westonruter
left a comment
There was a problem hiding this comment.
🤖 Comment by Claude Opus 5
A medium-effort review of the current diff. The root-cause analysis holds up, and change 1 — exempting sitemap requests in WP::handle_404() — looks correct and well placed: provider routes do map paged from the URL, so the elseif ( ! is_paged() ) branch would indeed never be reached.
The main suggestion is that change 2 be split, so 7.1.1 carries only what the fix requires.
The explicit 404 for an unregistered provider is load-bearing
This is worth stating plainly, because it reads like cleanup and is not. WP_Query::$is_sitemap is set from the bare presence of the query var (class-wp-query.php:828), so it is true for any value. /wp-sitemap-bogus-1.xml matches the provider rewrite rule and sets sitemap=bogus, so it is exempted by the new handle_404() branch — which would turn a request that currently 404s into a 200 if if ( ! $provider ) { return; } were left as-is. That branch does have to gain an explicit status.
But wp_die() is not required to do it, and carries costs
The blocking concern is new translatable strings: three are added here, and 7.1.1 is a minor release, so they would ship untranslated for every locale. Beyond that, wp_die() changes the request lifecycle — see the inline notes.
A smaller form for 7.1.1 would keep the idiom already used twice in this same method:
if ( ! $provider ) {
$wp_query->set_404();
status_header( 404 );
return;
}That leaves the sitemaps-disabled and empty-URL-list branches exactly as trunk has them, and holds the wp_die() conversion for 7.2, where it can be evaluated on its own merits rather than as a rider on a point-release bug fix.
Checked and cleared
wp_die( $msg, 404 )does set a 404 — the int$titleis converted toresponseinsidewp_die()itself.redirect_canonical()does not start redirecting sitemap URLs now thatis_404()is false; the explicitsitemap/sitemap-stylesheetguard atcanonical.php:709already covers that.- Dropping
global $wp_queryand its@globaltag is correct as the diff stands, since no other use remains — though it would need restoring under the suggestion above. - A concern that the new
wp_die()might break SEO plugins routing through the sharedsitemapquery var was investigated and does not apply. Yoast (inc/sitemaps/class-sitemaps.php:102) renders onpre_get_postsat priority 1, and Rank Math (includes/modules/sitemap/class-router.php:35) onparse_queryat priority 1; bothexitbeforetemplate_redirectfires at all.
Minor
The PR description states that "The sitemaps-disabled branch still uses set_404()", which no longer matches the diff.
Reverts the wp_die() conversion in WP_Sitemaps::render_sitemaps(), restoring the set_404() and status_header( 404 ) idiom the method already used. wp_die() terminates inside template_redirect, so later callbacks on that hook, template_include, and the theme's 404.php never run. It also dispatches through _xml_wp_die_handler() whenever the client sends an XML Accept header, which is plausible for a sitemap fetcher, and that handler applies htmlspecialchars() on top of the escaping already performed. None of this is needed to stop valid sitemap requests from being served with a 404, and the three new translatable strings it introduced cannot ship in a minor release. The explicit 404 for an unregistered provider is kept, because it is load bearing rather than cosmetic: WP_Query::$is_sitemap is set from the bare presence of the query var, so /wp-sitemap-bogus-1.xml matches the provider rewrite rule, is exempted by the new WP::handle_404() branch, and would otherwise be served with a 200. The sitemaps-disabled and empty-URL-list branches return to their trunk form, leaving the production change at three lines. Tests assert is_404() again rather than wp_die() messages. The subtype case is reshaped to cover the paged subtype route without depending on message text, and the unregistered provider test now asserts both halves of the interaction: no 404 from handle_404(), then a 404 from render_sitemaps(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WP::handle_404() now exempts any request carrying a `sitemap-stylesheet` query var. That var is registered by add_rewrite_tag(), so it is public and can be set on any URL, and the exemption tests only for its presence rather than for a value core actually serves. WP_Sitemaps_Stylesheet::render_stylesheet() sends an XML content type and echoes nothing for a type other than 'sitemap' or 'index', then exits. Combined, those two behaviors turned a request such as /does-not-exist/?sitemap-stylesheet=1 into a 200 with an empty body, where it previously returned a 404. Validate the type in WP_Sitemaps::render_sitemaps() and fall through to the same set_404() and status_header( 404 ) treatment already used for an unregistered provider, so the sitemaps component keeps deciding the status for every route handle_404() defers on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WP::handle_404() exempts sitemap requests on the raw query vars: WP_Query's is_sitemap flag is set from a non-empty `sitemap` value, and the `sitemap-stylesheet` check is a bare truthiness test. WP_Sitemaps::render_sitemaps() instead acted on the sanitized values, and bailed without a status when they were empty. Any value that is non-empty raw but sanitizes to an empty string therefore fell through both. handle_404() had already sent a 200, and nothing set a 404, so an arbitrary URL became a crawlable soft 404: - /this-page-does-not-exist/?sitemap=<> — sanitize_text_field() strips the tag. - /this-page-does-not-exist/?sitemap-stylesheet[]=sitemap — an array value is truthy, and sanitize_text_field() returns an empty string for arrays. Both returned 404 before sitemap requests were exempted from handle_404(). Test the raw query vars in the early bail, so it agrees with handle_404() about what a sitemap route is, then treat a route whose values did not survive sanitizing as a 404 rather than serving the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WP::handle_404() pairs its 404 with nocache_headers(); the branch that declines to 404 sends only status_header( 200 ). Now that sitemap requests are exempted there, every sitemap 404 is issued by WP_Sitemaps::render_sitemaps() instead, which sent a bare status_header( 404 ). A 404 for a sitemap route is frequently transient. /wp-sitemap-posts-post-9.xml is a 404 only until the site has enough posts to fill nine pages, and the sitemaps-disabled response lasts only as long as the filter returning false. An intermediary that caches 404s by default can hold on to any of these well past the point where the route became valid. Collect the three lines into a private send_404() helper and call it from each bail, so the no-cache headers cannot be forgotten at a future one. This also returns `global $wp_query` to the single method that now needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The changes are narrowly scoped, align with the described behavior, and are backed by targeted tests covering both the exemption and the relocated 404 decisions.
Pull request overview
Fixes sitemap endpoints returning HTTP 404 when the main query is empty (e.g., sites with zero published posts), by exempting sitemap requests from WP::handle_404() and ensuring sitemap-specific 404s are issued from the sitemaps component itself.
Changes:
- Update
WP::handle_404()to never set a 404 for sitemap or sitemap-stylesheet requests. - Update
WP_Sitemaps::render_sitemaps()to explicitly 404 for invalid/unservable sitemap routes (unregistered provider, unrecognized stylesheet type, and values that don’t survive sanitizing), centralizing sitemap 404 behavior. - Add/extend PHPUnit coverage for sitemap + stylesheet routes to verify the
handle_404()exemption and thatrender_sitemaps()still produces correct 404 outcomes.
File summaries
| File | Description |
|---|---|
| tests/phpunit/tests/wp/handle404.php | New tests asserting sitemap/stylesheet requests aren’t 404ed by WP::handle_404() when the main query is empty. |
| tests/phpunit/tests/sitemaps/sitemaps.php | Adds tests ensuring render_sitemaps() still sets 404 for invalid sitemap/stylesheet routes now exempted from handle_404(). |
| src/wp-includes/sitemaps/class-wp-sitemaps.php | Adds a centralized send_404() helper and explicit 404 bails in render_sitemaps() for invalid sitemap routes. |
| src/wp-includes/class-wp.php | Exempts sitemap and sitemap-stylesheet requests from WP::handle_404()’s default 404 behavior. |
Review details
Suppressed comments (1)
tests/phpunit/tests/wp/handle404.php:51
- The data provider key label is missing a space after the comma, which reads like a typo and is inconsistent with the other labels in the same provider.
'taxonomies provider,paged' => array( '/?sitemap=taxonomies&sitemap-subtype=category&paged=3' ),
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
render_sitemaps() runs on template_redirect for every front-end request, and on all but a handful the sitemap query vars are empty. Sanitizing four query vars before testing whether the request is a sitemap route at all means three sanitize_text_field() calls and an absint() on empty values, every page load, to reach a return statement. Move the bail above the assignments. The check reads the raw query vars, so it does not depend on them. Relocating the assignments puts them back under static analysis, which flags sanitize_text_field() as receiving mixed: get_query_var() can return any type, including an array when the request supplies one. Casting to string would convert an array to the string "Array" and emit a PHP warning, so read the values through a small typed accessor that maps a non-scalar to an empty string instead. sanitize_text_field() already returned an empty string for an array, so the behavior is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WP_UnitTestCase_Base::set_up() already clears the permalink structure before every test in a core run, so resetting it at the end of the test method achieved nothing. Placing it there was also fragile: a failing assertion throws, so the cleanup would be skipped in exactly the case where it would have mattered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment credited set_up() with dropping the dynamically registered sitemap query vars. It is tear_down() that does so, by assigning a fresh WP instance to the $wp global, which carries only the built-in public query vars. tear_down() also nulls $GLOBALS['wp_sitemaps'], so the wp_sitemaps_get_server() call in set_up() re-runs WP_Sitemaps::init() and registers them again. Name the actual mechanism, so a future reader looking for the reset finds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| if ( empty( $url_list ) ) { | ||
| $wp_query->set_404(); | ||
| status_header( 404 ); | ||
| $this->send_404(); | ||
| return; |
There was a problem hiding this comment.
Note, when testing this, it raised a question for me:
Something else I'm seeing: When there are no URLs, the /wp-sitemap.xml response will have a link to /wp-sitemap-posts-post-1.xml even when there are no URLs. When navigating to that URL, I am then served a 404 from the theme unexpectedly. I would have expected an XML response which tells the crawler that they successfully accessed the sitemap URL, but there are no URLs currently to index. Right? Is it valid to have a
<urlset>with no children?
🤖 Claude Opus 5 analysis:
Three separate answers, all verified.
1. No — an empty <urlset> is not schema-valid. From the actual XSD at sitemaps.org/schemas/sitemap/0.9/sitemap.xsd:
<xsd:sequence>
<xsd:any namespace="##other" minOccurs="0" maxOccurs="unbounded" processContents="strict"/>
<xsd:element name="url" type="tUrl" maxOccurs="unbounded"/>
</xsd:sequence>minOccurs is omitted on url, so it defaults to 1. At least one <url> is required. (Note the contrast with the xsd:any right above it, which sets minOccurs="0" explicitly — so the omission on url is deliberate, not an oversight.)
2. Your instinct is nonetheless the consensus — and it's already an accepted ticket. Core-61293, "Display an empty sitemap instead of 404," milestone 7.2, has-patch, reported by Sybre Waaijer of The SEO Framework. Their argument is exactly yours, with the schema caveat folded in: an empty sitemap makes Google Search Console say "Sitemap can be read, but has errors" rather than "Sitemap could not be read" — errors because the empty urlset is invalid, but "readable with errors" is a far more actionable signal for a site owner than a hard 404. So the proposal knowingly trades schema validity for diagnosability.
That's why this PR deliberately leaves the empty-URL-list branch alone, and why the description says so — it's 61293's call to make, in 7.2, where the milestone already is.
3. The specific case you describe doesn't come from core alone. I checked: with zero posts of every type, WP_Sitemaps_Posts::get_max_num_pages( 'post' ) returns max( 0, 0 ) === 0, so get_sitemap_entries()'s loop never runs and the index contains no posts-post entry at all. Emptying every post type in a test and dumping the index gives exactly one entry:
show_on_front=posts
INDEX: array ( 0 => 'http://example.org/?sitemap=posts&sitemap-subtype=page&paged=1' )
That one is listed because of the $min_num_pages = 1 special case at class-wp-sitemaps-posts.php:211, and it is not empty — line 108 prepends home_url( '/' ) to page 1 when show_on_front is posts. So it returns 200 with one URL.
For the index to advertise a sitemap that then 404s, something has to make get_max_num_pages() and get_url_list() disagree — which is precisely Cybr's scenario in 61293: a plugin filtering entries out for indexability while the page count still counts the underlying posts. So if you're seeing a link to wp-sitemap-posts-post-1.xml on a site with no posts, I'd look for a plugin on wp_sitemaps_posts_pre_max_num_pages or wp_sitemaps_posts_query_args — or a stale index, since these responses are cacheable. Worth confirming which site you saw it on; if it reproduces on a clean install I'd want to see it, because that would be a distinct bug from 61293.
The name promised a sanitization tailored to sitemap query vars, but all it did was call sanitize_text_field(), which is generic and far looser than these values actually allow: the rewrite rules constrain a provider name to [a-z]+, a subtype to [a-z\d_-]+, and a stylesheet type to sitemap or index. Naming that "get_sanitized_query_var" invites a reader to believe the values are validated when they are only stripped of tags. Restore the inline sanitize_text_field() calls, byte for byte as trunk has them. The helper was behavior-neutral in both directions, since sanitize_text_field() already returns an empty string for an array or null, so nothing changes. This leaves three pre-existing "expects string, mixed given" errors on those lines. They are trunk's, not this branch's, and the narrower validation that would resolve them properly belongs with the sitemap hardening planned for 7.2 rather than a point release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`WP::handle_404()` sets a 404 when the main query matches no posts and no exception applies. Sitemap requests were never among those exceptions; they were shielded only incidentally, by falling through to `is_home`, and in r62664 that fallthrough was removed. A site with no published posts therefore served a complete, valid sitemap under a 404 status, which search engines discard. Exempt sitemap and stylesheet routes there, alongside the existing admin, robots and favicon exceptions. Since `handle_404()` no longer decides the status for these requests, every sitemap 404 now has to be issued by `WP_Sitemaps::render_sitemaps()` instead: an unregistered provider, an unrecognized stylesheet type, and a route whose query vars do not survive `sanitize_text_field()` would each otherwise be served as a 200 on an arbitrary URL. These share a `send_404()` helper, which also sends the no-cache headers `handle_404()` was previously contributing, so an intermediary does not retain a 404 for a route that becomes valid once the site has more content. Sitemaps disabled via the `wp_sitemaps_enabled` filter, and providers with an empty URL list, keep the status they already had; whether the latter should render an empty sitemap instead is #61293. Developed in #13247. Follow-up to r48072, r48523, r62664. Props iamchitti, westonruter, fernandot, wildworks, harishtewari, l1onofjudah, luksusspokoju, abrahamfariaz, andreasca, siliconforks, adamsilverstein, audrasjb, ocean90, mrkenobi. See #39157, #61293. Fixes #65945. git-svn-id: https://develop.svn.wordpress.org/trunk@63570 602fd350-edb4-49c9-b593-d223f7449a82
`WP::handle_404()` sets a 404 when the main query matches no posts and no exception applies. Sitemap requests were never among those exceptions; they were shielded only incidentally, by falling through to `is_home`, and in r62664 that fallthrough was removed. A site with no published posts therefore served a complete, valid sitemap under a 404 status, which search engines discard. Exempt sitemap and stylesheet routes there, alongside the existing admin, robots and favicon exceptions. Since `handle_404()` no longer decides the status for these requests, every sitemap 404 now has to be issued by `WP_Sitemaps::render_sitemaps()` instead: an unregistered provider, an unrecognized stylesheet type, and a route whose query vars do not survive `sanitize_text_field()` would each otherwise be served as a 200 on an arbitrary URL. These share a `send_404()` helper, which also sends the no-cache headers `handle_404()` was previously contributing, so an intermediary does not retain a 404 for a route that becomes valid once the site has more content. Sitemaps disabled via the `wp_sitemaps_enabled` filter, and providers with an empty URL list, keep the status they already had; whether the latter should render an empty sitemap instead is #61293. Developed in WordPress/wordpress-develop#13247. Follow-up to r48072, r48523, r62664. Props iamchitti, westonruter, fernandot, wildworks, harishtewari, l1onofjudah, luksusspokoju, abrahamfariaz, andreasca, siliconforks, adamsilverstein, audrasjb, ocean90, mrkenobi. See #39157, #61293. Fixes #65945. Built from https://develop.svn.wordpress.org/trunk@63570 git-svn-id: http://core.svn.wordpress.org/trunk@62746 1a063a9b-81f0-0310-95a4-ce76da25c4cd
`WP::handle_404()` sets a 404 when the main query matches no posts and no exception applies. Sitemap requests were never among those exceptions; they were shielded only incidentally, by falling through to `is_home`, and in r62664 that fallthrough was removed. A site with no published posts therefore served a complete, valid sitemap under a 404 status, which search engines discard. Exempt sitemap and stylesheet routes there, alongside the existing admin, robots and favicon exceptions. Since `handle_404()` no longer decides the status for these requests, every sitemap 404 now has to be issued by `WP_Sitemaps::render_sitemaps()` instead: an unregistered provider, an unrecognized stylesheet type, and a route whose query vars do not survive `sanitize_text_field()` would each otherwise be served as a 200 on an arbitrary URL. These share a `send_404()` helper, which also sends the no-cache headers `handle_404()` was previously contributing, so an intermediary does not retain a 404 for a route that becomes valid once the site has more content. Sitemaps disabled via the `wp_sitemaps_enabled` filter, and providers with an empty URL list, keep the status they already had; whether the latter should render an empty sitemap instead is #61293. Developed in #13247. Follow-up to r48072, r48523, r62664. Reviewed by adamsilverstein. Merges r63570 to the 7.0 branch. Props iamchitti, westonruter, fernandot, wildworks, harishtewari, l1onofjudah, luksusspokoju, abrahamfariaz, andreasca, siliconforks, adamsilverstein, audrasjb, ocean90, mrkenobi. See #39157, #61293. Fixes #65945. git-svn-id: https://develop.svn.wordpress.org/branches/7.0@63573 602fd350-edb4-49c9-b593-d223f7449a82
`WP::handle_404()` sets a 404 when the main query matches no posts and no exception applies. Sitemap requests were never among those exceptions; they were shielded only incidentally, by falling through to `is_home`, and in r62664 that fallthrough was removed. A site with no published posts therefore served a complete, valid sitemap under a 404 status, which search engines discard. Exempt sitemap and stylesheet routes there, alongside the existing admin, robots and favicon exceptions. Since `handle_404()` no longer decides the status for these requests, every sitemap 404 now has to be issued by `WP_Sitemaps::render_sitemaps()` instead: an unregistered provider, an unrecognized stylesheet type, and a route whose query vars do not survive `sanitize_text_field()` would each otherwise be served as a 200 on an arbitrary URL. These share a `send_404()` helper, which also sends the no-cache headers `handle_404()` was previously contributing, so an intermediary does not retain a 404 for a route that becomes valid once the site has more content. Sitemaps disabled via the `wp_sitemaps_enabled` filter, and providers with an empty URL list, keep the status they already had; whether the latter should render an empty sitemap instead is #61293. Developed in WordPress/wordpress-develop#13247. Follow-up to r48072, r48523, r62664. Reviewed by adamsilverstein. Merges r63570 to the 7.0 branch. Props iamchitti, westonruter, fernandot, wildworks, harishtewari, l1onofjudah, luksusspokoju, abrahamfariaz, andreasca, siliconforks, adamsilverstein, audrasjb, ocean90, mrkenobi. See #39157, #61293. Fixes #65945. Built from https://develop.svn.wordpress.org/branches/7.0@63573 git-svn-id: http://core.svn.wordpress.org/branches/7.0@62749 1a063a9b-81f0-0310-95a4-ce76da25c4cd
`WP::handle_404()` sets a 404 when the main query matches no posts and no exception applies. Sitemap requests were never among those exceptions; they were shielded only incidentally, by falling through to `is_home`, and in r62664 that fallthrough was removed. A site with no published posts therefore served a complete, valid sitemap under a 404 status, which search engines discard. Exempt sitemap and stylesheet routes there, alongside the existing admin, robots and favicon exceptions. Since `handle_404()` no longer decides the status for these requests, every sitemap 404 now has to be issued by `WP_Sitemaps::render_sitemaps()` instead: an unregistered provider, an unrecognized stylesheet type, and a route whose query vars do not survive `sanitize_text_field()` would each otherwise be served as a 200 on an arbitrary URL. These share a `send_404()` helper, which also sends the no-cache headers `handle_404()` was previously contributing, so an intermediary does not retain a 404 for a route that becomes valid once the site has more content. Sitemaps disabled via the `wp_sitemaps_enabled` filter, and providers with an empty URL list, keep the status they already had; whether the latter should render an empty sitemap instead is #61293. Developed in #13247. Follow-up to r48072, r48523, r62664. Reviewed by adamsilverstein. Merges r63570 to the 7.1 branch. Merged to the 7.0 branch in error in r63573 and reverted in r63574. Props iamchitti, westonruter, fernandot, wildworks, harishtewari, l1onofjudah, luksusspokoju, abrahamfariaz, andreasca, siliconforks, adamsilverstein, audrasjb, ocean90, mrkenobi. See #39157, #61293. Fixes #65945. git-svn-id: https://develop.svn.wordpress.org/branches/7.1@63575 602fd350-edb4-49c9-b593-d223f7449a82
`WP::handle_404()` sets a 404 when the main query matches no posts and no exception applies. Sitemap requests were never among those exceptions; they were shielded only incidentally, by falling through to `is_home`, and in r62664 that fallthrough was removed. A site with no published posts therefore served a complete, valid sitemap under a 404 status, which search engines discard. Exempt sitemap and stylesheet routes there, alongside the existing admin, robots and favicon exceptions. Since `handle_404()` no longer decides the status for these requests, every sitemap 404 now has to be issued by `WP_Sitemaps::render_sitemaps()` instead: an unregistered provider, an unrecognized stylesheet type, and a route whose query vars do not survive `sanitize_text_field()` would each otherwise be served as a 200 on an arbitrary URL. These share a `send_404()` helper, which also sends the no-cache headers `handle_404()` was previously contributing, so an intermediary does not retain a 404 for a route that becomes valid once the site has more content. Sitemaps disabled via the `wp_sitemaps_enabled` filter, and providers with an empty URL list, keep the status they already had; whether the latter should render an empty sitemap instead is #61293. Developed in WordPress/wordpress-develop#13247. Follow-up to r48072, r48523, r62664. Reviewed by adamsilverstein. Merges r63570 to the 7.1 branch. Merged to the 7.0 branch in error in r63573 and reverted in r63574. Props iamchitti, westonruter, fernandot, wildworks, harishtewari, l1onofjudah, luksusspokoju, abrahamfariaz, andreasca, siliconforks, adamsilverstein, audrasjb, ocean90, mrkenobi. See #39157, #61293. Fixes #65945. Built from https://develop.svn.wordpress.org/branches/7.1@63575 git-svn-id: http://core.svn.wordpress.org/branches/7.1@62751 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Ticket
Trac ticket: https://core.trac.wordpress.org/ticket/65945
Summary
/wp-sitemap.xmlrenders correct XML but responds with HTTP 404, so search engines discard it.WP::handle_404()404s the main query when it has no posts and no exception applies. Sitemap requests aren't in that list — they were only shielded by falling through tois_home = true, which r62664 (Core-51542, Core-51543) correctly removed.Changes
WP::handle_404()— exempt sitemaps alongside the other machine-readable endpoints. Placed in the first branch rather than next tois_feed(), since provider routes mappagedfrom the URL and so never reach theelseif ( ! is_paged() )branch.WP_Sitemaps::render_sitemaps()— 404 explicitly for an unregistered provider, which previously relied onhandle_404()doing it.WP_Sitemaps::render_sitemaps()— 404 explicitly for an unrecognized stylesheet type.WP_Sitemaps::render_sitemaps()— 404 explicitly for a route whose query vars do not survive sanitizing.WP_Sitemaps::render_sitemaps()— sendnocache_headers()with every sitemap 404.Changes 2 through 4 are consequences of change 1 rather than cleanup. Once
handle_404()stops deciding the status for these requests, anything it now exempts andrender_sitemaps()declines to serve is left as a 200:/wp-sitemap-bogus-1.xmlmatches the provider rewrite rule and would fall through to the theme./does-not-exist/?sitemap-stylesheet=1reachesWP_Sitemaps_Stylesheet::render_stylesheet(), which sends an XML content type, echoes nothing for a type other thansitemaporindex, and exits — a 200 with an empty body./does-not-exist/?sitemap=<>and/does-not-exist/?sitemap-stylesheet[]=sitemapare exempted on the raw query var —WP_Query::$is_sitemapis! empty()and the stylesheet check is bare truthiness — butrender_sitemaps()acts on the sanitized value, which is empty in both cases, so it bailed without a status. The early bail now tests the same raw varshandle_404()does, so the two agree on what a sitemap route is.All of these are crawlable soft 404s on arbitrary URLs, and all returned 404 on trunk.
Change 5 restores something
handle_404()was providing. Its 404 branch callsnocache_headers(); the branch that declines to 404 sends onlystatus_header( 200 ). A sitemap 404 is frequently transient —/wp-sitemap-posts-post-9.xmlis a 404 only until the site has nine pages of posts — and intermediaries commonly cache 404s, so a bare 404 can outlive the condition that produced it. The three lines are collected into a privatesend_404()helper and called from each bail.The early bail is also moved above the sanitizing, since
render_sitemaps()runs ontemplate_redirectfor every front-end request and on nearly all of them both query vars are empty. The sanitizing itself is untouched, byte for byte as trunk has it.Note that this leaves three pre-existing
expects string, mixed givenreports on the relocatedsanitize_text_field()calls, sinceget_query_var()returnsmixed. Tightening that is worth doing, butsanitize_text_field()is much looser than these values allow — the rewrite rules constrain a provider name to[a-z]+, a subtype to[a-z\d_-]+, and a stylesheet type tositemaporindex— so the right fix is narrower validation, not a cast. That belongs with the 7.2 work below rather than a point release.The empty-URL-list branch keeps its existing behavior, so Core-61293 is unaffected. The production change is confined to
WP_Sitemaps::render_sitemaps(), one new private helper, and the three-linehandle_404()exemption.Known behavior, not addressed here
Because
sitemapandsitemap-stylesheetare public query vars, they can be appended to any path, so/no-such-page/?sitemap=indexnow returns the sitemap index with a 200 where trunk returned the same body with a 404. Requiring that the request matched a sitemap rewrite rule would fix that, but it would also break sitemaps on sites without pretty permalinks, where no rewrite rules apply and/?sitemap=indexis the only access path — a case the ticket explicitly relies on. Narrowing this belongs in a major release, not a point release.Deferred to 7.2: converting these bails to
wp_die()An earlier revision of this PR replaced the
set_404()bails withwp_die( $message, 404 ), giving each failure a descriptive message instead of the theme's generic 404. That remains desirable — the current responses tell an operator nothing about why a sitemap URL failed, and "no such provider" versus "provider registered but empty" is exactly the distinction someone debugging a missing sitemap needs. The intent is to revisit it for 7.2.It was reverted here because it is not needed to fix Core-65945 and does not belong in a minor release:
wp_die()form adds three translatable strings. 7.1.1 is a minor release, so they would ship untranslated in every locale.wp_die()ends the request insidetemplate_redirect. Later callbacks on that hook,template_include, and the theme's404.phpnever run. Sites currently serving a themed 404 for these URLs would start getting core's error page, and page-cache and 404-logging plugins hooked after priority 10 would stop seeing the requests.wp_die()dispatches through_xml_wp_die_handler()wheneverwp_is_xml_request()is true — i.e. whenever the client sendsAccept: text/xmlorapplication/rss+xml, both plausible for a sitemap fetcher. That handler emits an<error><message>document and applieshtmlspecialchars()on top of the escaping already applied, so a provider or subtype containing&renders double-escaped.None of that is disqualifying for a major release, where the behavior change can be evaluated on its own merits and the strings land with a full translation cycle. It is disqualifying for a point-release bug fix.
Test
tests/phpunit/tests/wp/handle404.php(12 tests): index, provider, paged, taxonomy, user and stylesheet routes with no posts, plus guards that unknown URLs and unregistered providers still 404.tests/phpunit/tests/sitemaps/sitemaps.php: the two pre-existingshould_return_404tests are unchanged from trunk. Cases are added for every routehandle_404()now defers on — empty URL list for a paged subtype, unregistered provider, unrecognized stylesheet type, and three data-provider cases for query vars that do not survive sanitizing. Each asserts both halves of the interaction:is_404()false aftergo_to(), proving the exemption applied, then true afterrender_sitemaps().--group sitemaps— 150 tests, OK--group wp --group query --group feed --group canonical --group rewrite— 1393 tests, OKScreenshots
The 404 responses, unchanged from trunk — the theme's own 404 template still renders:
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Root-cause investigation, tracing history, and drafting the fix and tests.