From 3971839357a990471be024fb08b348f76219bfaf Mon Sep 17 00:00:00 2001 From: Stef Mattana Date: Tue, 5 May 2026 17:00:43 +0100 Subject: [PATCH 1/2] Sitemaps: backtick column names via %i placeholders (fixes #48202) --- .../fix-48202-sitemap-backtick-columns | 4 ++ .../modules/sitemaps/sitemap-librarian.php | 42 ++++++++++++------- .../Jetpack_Sitemap_Librarian_Test.php | 41 ++++++++++++++++++ 3 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/fix-48202-sitemap-backtick-columns diff --git a/projects/plugins/jetpack/changelog/fix-48202-sitemap-backtick-columns b/projects/plugins/jetpack/changelog/fix-48202-sitemap-backtick-columns new file mode 100644 index 000000000000..1cdec3bca4e9 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-48202-sitemap-backtick-columns @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Sitemaps: Fix SQL error when wp_posts has a column whose name is a reserved SQL keyword. diff --git a/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php b/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php index a3544d81ea4f..07a923b81b97 100644 --- a/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php +++ b/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php @@ -302,18 +302,19 @@ public function query_posts_after_id( $from_id, $num_posts ) { } $post_types_list = implode( ',', $post_types ); - $columns_list = $this->get_sanitized_post_columns( $wpdb ); + $columns = $this->get_sanitized_post_columns( $wpdb ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPCS: db call ok; no-cache ok. return $wpdb->get_results( $wpdb->prepare( - "SELECT $columns_list + "SELECT {$columns['placeholders']} FROM $wpdb->posts WHERE post_status='publish' AND post_type IN ($post_types_list) AND ID>%d ORDER BY ID ASC LIMIT %d;", + ...$columns['columns'], $from_id, $num_posts ) @@ -445,18 +446,19 @@ public function query_most_recent_posts( $num_posts ) { $post_types_list = implode( ',', $post_types ); - $columns_list = $this->get_sanitized_post_columns( $wpdb ); + $columns = $this->get_sanitized_post_columns( $wpdb ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPCS: db call ok; no-cache ok. return $wpdb->get_results( $wpdb->prepare( - "SELECT $columns_list + "SELECT {$columns['placeholders']} FROM $wpdb->posts WHERE post_status='publish' AND post_date >= '%s' AND post_type IN ($post_types_list) ORDER BY post_date DESC LIMIT %d;", + ...$columns['columns'], $two_days_ago, $num_posts ) @@ -465,21 +467,33 @@ public function query_most_recent_posts( $num_posts ) { } /** - * Returns all columns from the posts table, - * except post_content and post_content_filtered. + * Returns all columns from the posts table, except post_content and post_content_filtered, + * along with a matching list of %i placeholders for safe use in wpdb::prepare(). + * + * Using %i identifier placeholders ensures column names are correctly backtick-quoted, + * which avoids SQL errors when a column name happens to be a reserved keyword + * (e.g. `order`, `key`, `group`). * * @param object $wpdb The WordPress database object. - * @return string The sanitized post columns. + * @return array { + * @type string $placeholders Comma-separated list of %i placeholders, one per column. + * @type string[] $columns The column names to feed into wpdb::prepare(). + * } */ private function get_sanitized_post_columns( $wpdb ) { - $columns = array_filter( - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->get_col( "SHOW COLUMNS FROM $wpdb->posts" ), - function ( $column ) { - return $column !== 'post_content' && $column !== 'post_content_filtered'; - } + $columns = array_values( + array_filter( + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->get_col( "SHOW COLUMNS FROM $wpdb->posts" ), + function ( $column ) { + return $column !== 'post_content' && $column !== 'post_content_filtered'; + } + ) ); - return implode( ',', array_map( 'esc_sql', $columns ) ); + return array( + 'placeholders' => implode( ',', array_fill( 0, count( $columns ), '%i' ) ), + 'columns' => $columns, + ); } } diff --git a/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php b/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php index 78e8bc94f839..780f96b382fa 100644 --- a/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php @@ -164,4 +164,45 @@ public function test_sitemap_librarian_delete_contiguously_named_rows() { $this->assertTrue( $librarian->read_sitemap_data( 'name-2', 'type' ) === null ); $this->assertTrue( $librarian->read_sitemap_data( 'name-3', 'type' ) === null ); } + + /** + * Regression test for https://github.com/Automattic/jetpack/issues/48202 + * + * If wp_posts has a column whose name is a reserved SQL keyword (e.g. `order`), + * the SELECT used by the sitemap query must still succeed because column names + * are passed as %i identifier placeholders to wpdb::prepare(). + * + * @group jetpack-sitemap + */ + #[Group( 'jetpack-sitemap' )] + public function test_query_posts_after_id_handles_reserved_keyword_columns() { + global $wpdb; + + // Add a column whose name is a reserved SQL keyword. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "ALTER TABLE {$wpdb->posts} ADD COLUMN `order` INT NULL" ); + + try { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'post', + ) + ); + + $librarian = new Jetpack_Sitemap_Librarian(); + $results = $librarian->query_posts_after_id( 0, 10 ); + + // The query must run without raising a SQL error. + $this->assertSame( '', $wpdb->last_error ); + $this->assertIsArray( $results ); + + // And the post we just created should come back. + $ids = wp_list_pluck( $results, 'ID' ); + $this->assertContains( (string) $post_id, array_map( 'strval', $ids ) ); + } finally { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "ALTER TABLE {$wpdb->posts} DROP COLUMN `order`" ); + } + } } From a1c4b5e0be647a354b8ef2b6ddfe2a8d335255cf Mon Sep 17 00:00:00 2001 From: Stef Mattana Date: Tue, 5 May 2026 17:26:24 +0100 Subject: [PATCH 2/2] Fix positional args after spread in wpdb->prepare, guard ALTER TABLE in test --- .../modules/sitemaps/sitemap-librarian.php | 8 ++------ .../Jetpack_Sitemap_Librarian_Test.php | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php b/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php index 07a923b81b97..210a8cc66e12 100644 --- a/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php +++ b/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php @@ -314,9 +314,7 @@ public function query_posts_after_id( $from_id, $num_posts ) { AND ID>%d ORDER BY ID ASC LIMIT %d;", - ...$columns['columns'], - $from_id, - $num_posts + ...array_merge( $columns['columns'], array( $from_id, $num_posts ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -458,9 +456,7 @@ public function query_most_recent_posts( $num_posts ) { AND post_type IN ($post_types_list) ORDER BY post_date DESC LIMIT %d;", - ...$columns['columns'], - $two_days_ago, - $num_posts + ...array_merge( $columns['columns'], array( $two_days_ago, $num_posts ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder,WordPress.DB.PreparedSQL.InterpolatedNotPrepared diff --git a/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php b/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php index 780f96b382fa..7df41cedbdeb 100644 --- a/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php @@ -178,9 +178,15 @@ public function test_sitemap_librarian_delete_contiguously_named_rows() { public function test_query_posts_after_id_handles_reserved_keyword_columns() { global $wpdb; - // Add a column whose name is a reserved SQL keyword. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( "ALTER TABLE {$wpdb->posts} ADD COLUMN `order` INT NULL" ); + // Check whether the column already exists before adding it. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $column_existed = (bool) $wpdb->get_var( "SHOW COLUMNS FROM {$wpdb->posts} LIKE 'order'" ); + + if ( ! $column_existed ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "ALTER TABLE {$wpdb->posts} ADD COLUMN `order` INT NULL" ); + $this->assertEmpty( $wpdb->last_error, 'ALTER TABLE to add `order` column failed.' ); + } try { $post_id = self::factory()->post->create( @@ -201,8 +207,11 @@ public function test_query_posts_after_id_handles_reserved_keyword_columns() { $ids = wp_list_pluck( $results, 'ID' ); $this->assertContains( (string) $post_id, array_map( 'strval', $ids ) ); } finally { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( "ALTER TABLE {$wpdb->posts} DROP COLUMN `order`" ); + // Only drop the column if we added it — don't mutate a pre-existing schema. + if ( ! $column_existed ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "ALTER TABLE {$wpdb->posts} DROP COLUMN `order`" ); + } } } }