Skip to content

Commit 8fe46cd

Browse files
committed
fix: prevent concurrent duplicate post slugs
1 parent acebfd0 commit 8fe46cd

1 file changed

Lines changed: 98 additions & 20 deletions

File tree

src/wp-includes/post.php

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4849,8 +4849,6 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true )
48494849
$post_name = wp_add_trashed_suffix_to_post_name_for_post( $post_id );
48504850
}
48514851

4852-
$post_name = wp_unique_post_slug( $post_name, $post_id, $post_status, $post_type, $post_parent );
4853-
48544852
// Don't unslash.
48554853
$post_mime_type = $postarr['post_mime_type'] ?? '';
48564854

@@ -4927,6 +4925,81 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true )
49274925
$data = wp_unslash( $data );
49284926
$where = array( 'ID' => $post_id );
49294927

4928+
if ( ! $update ) {
4929+
// If there is a suggested ID, use it if not already present.
4930+
if ( ! empty( $import_id ) ) {
4931+
$import_id = (int) $import_id;
4932+
4933+
if ( ! $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE ID = %d", $import_id ) ) ) {
4934+
$data['ID'] = $import_id;
4935+
}
4936+
}
4937+
}
4938+
4939+
$data_post_status = $data['post_status'];
4940+
$data_post_type = $data['post_type'];
4941+
$data_post_parent = (int) $data['post_parent'];
4942+
4943+
/*
4944+
* Acquire a MySQL advisory lock to eliminate the TOCTOU race between the
4945+
* wp_unique_post_slug() SELECT and the INSERT/UPDATE below. Without this lock
4946+
* two concurrent requests can both observe the same slug as free and write
4947+
* duplicate post_name values.
4948+
*
4949+
* A single posts-table-scoped lock is used because attachment slug uniqueness
4950+
* overlaps all post types: using narrower locks allows an attachment and another
4951+
* post type to miss each other and write the same slug concurrently.
4952+
*
4953+
* The lock is skipped for statuses and post types where wp_unique_post_slug()
4954+
* performs no DB query (draft, pending, auto-draft, revisions, user_request,
4955+
* nav_menu_item), since no slug allocation race is possible there.
4956+
*/
4957+
$needs_slug_lock = ! (
4958+
in_array( $data_post_status, array( 'draft', 'pending', 'auto-draft' ), true )
4959+
|| ( 'inherit' === $data_post_status && 'revision' === $data_post_type )
4960+
|| 'user_request' === $data_post_type
4961+
|| 'nav_menu_item' === $data_post_type
4962+
);
4963+
4964+
$slug_lock_name = '';
4965+
$lock_acquired = false;
4966+
4967+
if ( $needs_slug_lock ) {
4968+
$slug_lock_name = 'wp_post_slug_' . md5( $wpdb->posts );
4969+
4970+
/**
4971+
* Filters the timeout in seconds for the advisory slug lock used during wp_insert_post().
4972+
*
4973+
* When the lock cannot be acquired within this many seconds the insertion or update
4974+
* fails closed to preserve slug uniqueness under concurrent load.
4975+
*
4976+
* @since x.x.x
4977+
*
4978+
* @param int $timeout Lock wait timeout in seconds. Default 10.
4979+
* @param string $slug_lock_name Advisory lock name, scoped to the posts table.
4980+
* @param string $post_type Post type being inserted or updated.
4981+
*/
4982+
$timeout = max( 0, (int) apply_filters( 'wp_post_slug_lock_timeout', 10, $slug_lock_name, $data_post_type ) );
4983+
$lock_result = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $slug_lock_name, $timeout ) );
4984+
$lock_acquired = '1' === (string) $lock_result;
4985+
4986+
if ( ! $lock_acquired ) {
4987+
if ( $wp_error ) {
4988+
return new WP_Error( 'db_lock_error', __( 'Could not acquire post slug lock.' ), $wpdb->last_error );
4989+
}
4990+
4991+
return 0;
4992+
}
4993+
}
4994+
4995+
$data['post_name'] = wp_unique_post_slug(
4996+
$data['post_name'],
4997+
$post_id,
4998+
$data_post_status,
4999+
$data_post_type,
5000+
$data_post_parent
5001+
);
5002+
49305003
if ( $update ) {
49315004
/**
49325005
* Fires immediately before an existing post is updated in the database.
@@ -4937,8 +5010,23 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true )
49375010
* @param array $data Array of unslashed post data.
49385011
*/
49395012
do_action( 'pre_post_update', $post_id, $data );
5013+
} else {
5014+
/**
5015+
* Fires immediately before a new post is inserted in the database.
5016+
*
5017+
* @since 6.9.0
5018+
*
5019+
* @param array $data Array of unslashed post data.
5020+
*/
5021+
do_action( 'pre_post_insert', $data );
5022+
}
49405023

5024+
if ( $update ) {
49415025
if ( false === $wpdb->update( $wpdb->posts, $data, $where ) ) {
5026+
if ( $lock_acquired ) {
5027+
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $slug_lock_name ) );
5028+
}
5029+
49425030
if ( $wp_error ) {
49435031
if ( 'attachment' === $post_type ) {
49445032
$message = __( 'Could not update attachment in the database.' );
@@ -4952,25 +5040,11 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true )
49525040
}
49535041
}
49545042
} else {
4955-
// If there is a suggested ID, use it if not already present.
4956-
if ( ! empty( $import_id ) ) {
4957-
$import_id = (int) $import_id;
4958-
4959-
if ( ! $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE ID = %d", $import_id ) ) ) {
4960-
$data['ID'] = $import_id;
5043+
if ( false === $wpdb->insert( $wpdb->posts, $data ) ) {
5044+
if ( $lock_acquired ) {
5045+
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $slug_lock_name ) );
49615046
}
4962-
}
4963-
4964-
/**
4965-
* Fires immediately before a new post is inserted in the database.
4966-
*
4967-
* @since 6.9.0
4968-
*
4969-
* @param array $data Array of unslashed post data.
4970-
*/
4971-
do_action( 'pre_post_insert', $data );
49725047

4973-
if ( false === $wpdb->insert( $wpdb->posts, $data ) ) {
49745048
if ( $wp_error ) {
49755049
if ( 'attachment' === $post_type ) {
49765050
$message = __( 'Could not insert attachment into the database.' );
@@ -4991,12 +5065,16 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true )
49915065
}
49925066

49935067
if ( empty( $data['post_name'] ) && ! in_array( $data['post_status'], array( 'draft', 'pending', 'auto-draft' ), true ) ) {
4994-
$data['post_name'] = wp_unique_post_slug( sanitize_title( $data['post_title'], $post_id ), $post_id, $data['post_status'], $post_type, $post_parent );
5068+
$data['post_name'] = wp_unique_post_slug( sanitize_title( $data['post_title'], $post_id ), $post_id, $data_post_status, $data_post_type, $data_post_parent );
49955069

49965070
$wpdb->update( $wpdb->posts, array( 'post_name' => $data['post_name'] ), $where );
49975071
clean_post_cache( $post_id );
49985072
}
49995073

5074+
if ( $lock_acquired ) {
5075+
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $slug_lock_name ) );
5076+
}
5077+
50005078
if ( is_object_in_taxonomy( $post_type, 'category' ) ) {
50015079
wp_set_post_categories( $post_id, $post_category );
50025080
}

0 commit comments

Comments
 (0)