Skip to content

Commit 285d4d1

Browse files
committed
Sitemaps: Don't 404 valid sitemaps on sites with no posts.
`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#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
1 parent 27cb801 commit 285d4d1

4 files changed

Lines changed: 268 additions & 10 deletions

File tree

‎src/wp-includes/class-wp.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,8 +746,9 @@ public function handle_404() {
746746

747747
$set_404 = true;
748748

749-
// Never 404 for the admin, robots, or favicon.
750-
if ( is_admin() || is_robots() || is_favicon() ) {
749+
// Never 404 here for the admin, robots, favicon, or sitemaps.
750+
// Sitemap routes send their own status in WP_Sitemaps::render_sitemaps().
751+
if ( is_admin() || is_robots() || is_favicon() || is_sitemap() || get_query_var( 'sitemap-stylesheet' ) ) {
751752
$set_404 = false;
752753

753754
// If posts were found, check for paged content.

‎src/wp-includes/sitemaps/class-wp-sitemaps.php‎

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,30 +157,45 @@ public function register_rewrites() {
157157
* Renders sitemap templates based on rewrite rules.
158158
*
159159
* @since 5.5.0
160-
*
161-
* @global WP_Query $wp_query WordPress Query object.
162160
*/
163161
public function render_sitemaps() {
164-
global $wp_query;
162+
/*
163+
* Bail early if this isn't a sitemap or stylesheet route.
164+
*
165+
* This runs on every front-end request, so it comes before any
166+
* sanitizing. The raw query vars are tested here, matching
167+
* WP::handle_404(), which exempts sitemap requests from its own 404 on
168+
* the same basis. Testing the sanitized values instead would let a
169+
* request that handle_404() exempted fall through both, leaving it a 200.
170+
*/
171+
if ( ! get_query_var( 'sitemap' ) && ! get_query_var( 'sitemap-stylesheet' ) ) {
172+
return;
173+
}
165174

166175
$sitemap = sanitize_text_field( get_query_var( 'sitemap' ) );
167176
$object_subtype = sanitize_text_field( get_query_var( 'sitemap-subtype' ) );
168177
$stylesheet_type = sanitize_text_field( get_query_var( 'sitemap-stylesheet' ) );
169178
$paged = absint( get_query_var( 'paged' ) );
170179

171-
// Bail early if this isn't a sitemap or stylesheet route.
180+
// Force a 404 and bail early if the route did not survive sanitizing.
172181
if ( ! ( $sitemap || $stylesheet_type ) ) {
182+
$this->send_404();
173183
return;
174184
}
175185

176186
if ( ! $this->sitemaps_enabled() ) {
177-
$wp_query->set_404();
178-
status_header( 404 );
187+
$this->send_404();
179188
return;
180189
}
181190

182191
// Render stylesheet if this is stylesheet route.
183192
if ( $stylesheet_type ) {
193+
// Force a 404 and bail early if the stylesheet type is not recognized.
194+
if ( ! in_array( $stylesheet_type, array( 'sitemap', 'index' ), true ) ) {
195+
$this->send_404();
196+
return;
197+
}
198+
184199
$stylesheet = new WP_Sitemaps_Stylesheet();
185200

186201
$stylesheet->render_stylesheet( $stylesheet_type );
@@ -197,7 +212,9 @@ public function render_sitemaps() {
197212

198213
$provider = $this->registry->get_provider( $sitemap );
199214

215+
// Force a 404 and bail early if the requested provider is not registered.
200216
if ( ! $provider ) {
217+
$this->send_404();
201218
return;
202219
}
203220

@@ -209,15 +226,34 @@ public function render_sitemaps() {
209226

210227
// Force a 404 and bail early if no URLs are present.
211228
if ( empty( $url_list ) ) {
212-
$wp_query->set_404();
213-
status_header( 404 );
229+
$this->send_404();
214230
return;
215231
}
216232

217233
$this->renderer->render_sitemap( $url_list );
218234
exit;
219235
}
220236

237+
/**
238+
* Sends a 404 for a sitemap route that cannot be served.
239+
*
240+
* WP::handle_404() exempts sitemap requests, so every sitemap 404 is issued
241+
* here instead. That includes the no-cache headers handle_404() sends with
242+
* its own 404, so an intermediary does not retain a 404 for a route that
243+
* becomes valid once the site has more content.
244+
*
245+
* @since 7.1.1
246+
*
247+
* @global WP_Query $wp_query WordPress Query object.
248+
*/
249+
private function send_404(): void {
250+
global $wp_query;
251+
252+
$wp_query->set_404();
253+
status_header( 404 );
254+
nocache_headers();
255+
}
256+
221257
/**
222258
* Redirects a URL to the wp-sitemap.xml
223259
*

‎tests/phpunit/tests/sitemaps/sitemaps.php‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,4 +493,108 @@ public function test_empty_url_list_should_return_404() {
493493

494494
$this->assertTrue( is_404() );
495495
}
496+
497+
/**
498+
* Ensures a paged subtype route with no URLs still 404s, now that
499+
* WP::handle_404() no longer sets a 404 for sitemap requests.
500+
*
501+
* @ticket 65945
502+
*/
503+
public function test_empty_url_list_for_subtype_should_return_404() {
504+
wp_register_sitemap_provider( 'foo', new WP_Sitemaps_Empty_Test_Provider( 'foo' ) );
505+
506+
$this->go_to( home_url( '/?sitemap=foo&sitemap-subtype=bar&paged=2' ) );
507+
508+
wp_sitemaps_get_server()->render_sitemaps();
509+
510+
$this->assertTrue( is_404() );
511+
}
512+
513+
/**
514+
* Ensures a sitemap query var that does not survive sanitizing 404s.
515+
*
516+
* WP::handle_404() exempts these requests on the raw query var, while
517+
* render_sitemaps() acts on the sanitized value. Without a matching bail
518+
* they fall through both and an arbitrary URL is served as a 200.
519+
*
520+
* @ticket 65945
521+
*
522+
* @dataProvider data_unusable_sitemap_query_vars
523+
*
524+
* @param non-falsy-string $query_string Query string to append to a nonexistent URL.
525+
*/
526+
public function test_unusable_sitemap_query_var_should_return_404( string $query_string ) {
527+
$this->set_permalink_structure( '/%postname%/' );
528+
529+
// Instantiate the server before navigating: registering the sitemap
530+
// rewrite tags is what adds the query vars to `$wp->public_query_vars`.
531+
$sitemaps = wp_sitemaps_get_server();
532+
533+
$this->go_to( home_url( '/this-page-does-not-exist/' . $query_string ) );
534+
535+
$this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' );
536+
537+
$sitemaps->render_sitemaps();
538+
539+
$this->assertTrue( is_404(), 'render_sitemaps() should have set a 404.' );
540+
}
541+
542+
/**
543+
* Data provider.
544+
*
545+
* @return array<non-falsy-string, array{ non-falsy-string }>
546+
*/
547+
public function data_unusable_sitemap_query_vars(): array {
548+
return array(
549+
'value stripped by sanitizing' => array( '?sitemap=<>' ),
550+
'array sitemap value' => array( '?sitemap[]=index' ),
551+
'array stylesheet value' => array( '?sitemap-stylesheet[]=sitemap' ),
552+
);
553+
}
554+
555+
/**
556+
* Ensures an unrecognized stylesheet type 404s from render_sitemaps().
557+
*
558+
* WP::handle_404() exempts any request carrying a `sitemap-stylesheet`
559+
* query var, and WP_Sitemaps_Stylesheet::render_stylesheet() echoes nothing
560+
* for a type other than 'sitemap' or 'index', so this route would otherwise
561+
* be served as a 200 with an empty body.
562+
*
563+
* @ticket 65945
564+
*/
565+
public function test_unrecognized_stylesheet_type_should_return_404() {
566+
// Instantiate the server before navigating: registering the sitemap rewrite
567+
// tags is what adds `sitemap-stylesheet` to `$wp->public_query_vars`.
568+
$sitemaps = wp_sitemaps_get_server();
569+
570+
$this->go_to( home_url( '/?sitemap-stylesheet=this-is-not-a-stylesheet' ) );
571+
572+
$this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' );
573+
574+
$sitemaps->render_sitemaps();
575+
576+
$this->assertTrue( is_404(), 'render_sitemaps() should have set a 404.' );
577+
}
578+
579+
/**
580+
* Ensures an unregistered provider 404s from render_sitemaps().
581+
*
582+
* WP::handle_404() exempts every sitemap request, so this route would
583+
* otherwise be served with a 200.
584+
*
585+
* @ticket 65945
586+
*/
587+
public function test_unregistered_provider_should_return_404() {
588+
// Instantiate the server before navigating: registering the sitemap
589+
// rewrite tags is what adds `sitemap` to `$wp->public_query_vars`.
590+
$sitemaps = wp_sitemaps_get_server();
591+
592+
$this->go_to( home_url( '/?sitemap=this-provider-does-not-exist' ) );
593+
594+
$this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' );
595+
596+
$sitemaps->render_sitemaps();
597+
598+
$this->assertTrue( is_404(), 'render_sitemaps() should have set a 404.' );
599+
}
496600
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/**
4+
* @group wp
5+
* @group sitemaps
6+
*
7+
* @covers WP::handle_404
8+
*/
9+
class Tests_WP_Handle404 extends WP_UnitTestCase {
10+
11+
public function set_up() {
12+
parent::set_up();
13+
14+
$this->set_permalink_structure( '/%postname%/' );
15+
16+
/*
17+
* Priming the server re-registers the sitemap query vars. tear_down()
18+
* replaces the $wp global with a fresh WP instance, which carries only
19+
* the built-in public query vars, and nulls $GLOBALS['wp_sitemaps'] so
20+
* that this call re-runs WP_Sitemaps::init() and adds them back.
21+
*/
22+
wp_sitemaps_get_server();
23+
}
24+
25+
/**
26+
* A sitemap request must not be turned into a 404 by an empty main query.
27+
*
28+
* Whether the sitemap exists is decided later by WP_Sitemaps::render_sitemaps(),
29+
* so some of these URLs still 404 in a full request, just not from here.
30+
*
31+
* @ticket 65945
32+
*
33+
* @dataProvider data_sitemap_requests
34+
*
35+
* @param non-falsy-string $url Sitemap URL to request.
36+
*/
37+
public function test_sitemap_requests_should_not_be_404ed_by_an_empty_main_query( string $url ) {
38+
$this->go_to( home_url( $url ) );
39+
40+
$this->assertTrue( is_sitemap(), 'The request should be recognized as a sitemap request.' );
41+
$this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' );
42+
}
43+
44+
/**
45+
* Data provider.
46+
*
47+
* @return array<non-falsy-string, array{ non-falsy-string }>
48+
*/
49+
public function data_sitemap_requests(): array {
50+
return array(
51+
'index' => array( '/?sitemap=index' ),
52+
'posts provider' => array( '/?sitemap=posts&sitemap-subtype=post' ),
53+
'posts provider, paged' => array( '/?sitemap=posts&sitemap-subtype=post&paged=2' ),
54+
'pages provider, paged' => array( '/?sitemap=posts&sitemap-subtype=page&paged=2' ),
55+
'taxonomies provider' => array( '/?sitemap=taxonomies&sitemap-subtype=category' ),
56+
'taxonomies provider,paged' => array( '/?sitemap=taxonomies&sitemap-subtype=category&paged=3' ),
57+
'users provider, paged' => array( '/?sitemap=users&paged=2' ),
58+
);
59+
}
60+
61+
/**
62+
* The sitemap stylesheet routes must not be 404ed either.
63+
*
64+
* Covered separately because is_sitemap() only reflects the `sitemap` query var.
65+
*
66+
* @ticket 65945
67+
*
68+
* @dataProvider data_sitemap_stylesheet_requests
69+
*
70+
* @param non-falsy-string $url Stylesheet URL to request.
71+
*/
72+
public function test_sitemap_stylesheet_requests_should_not_be_404ed_by_an_empty_main_query( string $url ) {
73+
$this->go_to( home_url( $url ) );
74+
75+
$this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' );
76+
}
77+
78+
/**
79+
* Data provider.
80+
*
81+
* @return array<non-falsy-string, array{ non-falsy-string }>
82+
*/
83+
public function data_sitemap_stylesheet_requests(): array {
84+
return array(
85+
'sitemap stylesheet' => array( '/?sitemap-stylesheet=sitemap' ),
86+
'index stylesheet' => array( '/?sitemap-stylesheet=index' ),
87+
// Not a real route, but the only stylesheet case is_home() doesn't already cover.
88+
'sitemap stylesheet, paged' => array( '/?sitemap-stylesheet=sitemap&paged=2' ),
89+
);
90+
}
91+
92+
/**
93+
* A genuinely unknown URL must still 404.
94+
*
95+
* @ticket 65945
96+
*/
97+
public function test_non_sitemap_request_should_still_404() {
98+
$this->go_to( home_url( '/this-page-does-not-exist/' ) );
99+
100+
$this->assertFalse( is_sitemap(), 'The request should not be a sitemap request.' );
101+
$this->assertTrue( is_404(), 'An unknown URL should still be a 404.' );
102+
}
103+
104+
/**
105+
* An unregistered sitemap provider must not be turned into a 404 here.
106+
*
107+
* render_sitemaps() sends that status itself, covered in Tests_Sitemaps_Sitemaps.
108+
*
109+
* @ticket 65945
110+
*/
111+
public function test_unregistered_sitemap_provider_should_not_404_in_handle_404() {
112+
$this->go_to( home_url( '/?sitemap=this-provider-does-not-exist' ) );
113+
114+
$this->assertTrue( is_sitemap(), 'The request should be recognized as a sitemap request.' );
115+
$this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' );
116+
}
117+
}

0 commit comments

Comments
 (0)