Make WordPress Core

Opened 2 years ago

Last modified 5 months ago

#61996 new defect (bug)

Slug Conflict When a Published and Draft Page Share post_name

Reported by: brookedot Owned by:
Priority: normal Milestone: Future Release
Component: Posts, Post Types Version:
Severity: normal Keywords: good-first-bug has-test-info has-patch has-unit-tests
Cc: Focuses:

Description

It is possible (although it takes some work) to have a published page and a draft page that shares the same post_name. When this occurs if the draft page has a lower ID then it is retrieved by get_page_by_path in place of the published page. This is due at least in part to get_page_by_path not checking the page status and .

  1. Ensure permalinks are anything other than Plain
  2. Publish three nested pages, the first page here is needed to avoid a slug conflict when you later create pages with the same name. For example, to prevent parent from becoming parent-2. The posts must be published so they can be selected as parent pages. Consider setting the page content to "this is draft" so it's easy to tell these apart later.
  • Placeholder (slug placeholder)
    • Parent (slug /placehoder/parent
      • Child ( slug placeholder/parent/child
  1. Using the Bulk Action, set all three newly created published posts to draft. There may be other ways to do this, as well.
  1. Publish two addition new pages with the same slugs as parent and child
  • Parent ( slug parent )
    • Child (slug parent/child)
  1. Using Quick Edit remove the parent from the draft page parent ( /placeholder/parent) by setting its parent page to `Main (no parent) keeping it as a draft.

You should now have parent and parent/child as drafts alongside your other two published pages.

  1. Ensure you don't have a -2 in either parent-2 or child-2
  1. If all goes well, when you visit parent/child you will now see a 404 instead of the pages you published in step 4.

There is a screen recording of these steps here:
https://cloudup.com/cxkwjscxDfB

They can be replicated in Multisite, Single site, and WordPress playground.

Some insight into what is happening is when you return the array used to get_page_by_path. For WordPress 6.6.1 that is https://github.com/WordPress/WordPress/blob/6.6.1/wp-includes/post.php#L5881

Using var_dump to return the array:

<?php
        $sql                 = "
                SELECT ID, post_name, post_parent, post_type
                FROM $wpdb->posts
                WHERE post_name IN ($in_string)
                AND post_type IN ($post_type_in_string)
        ";

        $pages = $wpdb->get_results( $sql, ARRAY_A );

        $revparts = array_reverse( $parts );
        var_dump( $pages );

This returns post ID 63 first which is our draft child page thus a 404 on the front end. ID 69 is the published child page which is never displayed on the front end as ID 63 is returned first.

Here is the output of the above var_dump()

<?php
array (size=4)
  0 => 
    array (size=4)
      'ID' => string '63' (length=2)
      'post_name' => string 'child' (length=5)
      'post_parent' => string '61' (length=2)
      'post_type' => string 'page' (length=4)
  1 => 
    array (size=4)
      'ID' => string '69' (length=2)
      'post_name' => string 'child' (length=5)
      'post_parent' => string '65' (length=2)
      'post_type' => string 'page' (length=4)
  2 => 
    array (size=4)
      'ID' => string '61' (length=2)
      'post_name' => string 'parent' (length=6)
      'post_parent' => string '0' (length=1)
      'post_type' => string 'page' (length=4)
  3 => 
    array (size=4)
      'ID' => string '65' (length=2)
      'post_name' => string 'parent' (length=6)
      'post_parent' => string '0' (length=1)
      'post_type' => string 'page' (length=4)

A few other related trac issues were found but nothing that matched this exactly. Please update the Component to match the closest, I almost set this as permalinks

Related to #13459 (but not a duplicate as this is the same post type)

Props @trepmal who was instrumental in debugging and reporting this bug.

Change History (5)

#1 @SirLouen
13 months ago

  • Keywords needs-patch good-first-bug has-test-info added
  • Milestone Awaiting ReviewFuture Release

Reproduction Report

Description

✅ This report validates that the issue can be reproduced.

Environment

  • WordPress: 6.9-alpha-60093-src
  • PHP: 8.2.29
  • Server: nginx/1.29.1
  • Database: mysqli (Server: 8.4.6 / Client: mysqlnd 8.2.29)
  • Browser: Chrome 139.0.0.0
  • OS: Windows 10/11
  • Theme: Twenty Twenty-Five 1.3
  • MU Plugins: None activated
  • Plugins:
    • Test Reports 1.2.0

Test Instructions

  • Used the ones provided by the OP.
  • 🐞 Both child pages are linked as the same page following such procedure.

Actual Results

  1. ✅ Error condition occurs (reproduced).

Additional Notes

  • I'm not going to add a video because the one provided in the OP works as expected. I did a little twist to the instructions as follows with the same result:
  1. Create the Placeholder > First Parent > First Child pages
  2. Create the Second Parent > Second Child page
  3. Set to draft First Parent and Child Pages
  4. Set First parent's parent to Main instead of Placeholder
  5. We can see that both Second And First Child page point to the First Child Page
  • It seems that the problem comes the moment we set the First Parent to root, leaving the link to Placeholder. After this, both posts share the same level hierarchy and the same post_name which causes a collision. Same happens for the First Parent and Second parent as they also share the same post_name. When changing the hierarchy, the system is not checking for collisions in post_name hence adding the corresponding -2, -3 like when we create a new post with the exact same slug.

This ticket was mentioned in PR #9675 on WordPress/wordpress-develop by @sahilgidwani.


13 months ago
#2

  • Keywords has-patch added; needs-patch removed

This PR updates get_page_by_path() to exclude non-public post statuses (such as draft, pending, and trash) from path resolution. Previously, unpublished pages sharing a slug with a published one could incorrectly hijack URL resolution and return the wrong post.

### Changes Made

  • Added filtering by valid post_status using get_post_stati( array( 'public' => true, 'private' => true ) ).
  • Ensured only published and intentionally viewable posts are considered when resolving paths.
  • Prevented draft or pending posts from conflicting with live URLs.

This ticket was mentioned in PR #11414 on WordPress/wordpress-develop by rdelbem.


5 months ago
#3

  • Keywords has-unit-tests added

Summary

Fixes #61996 where get_page_by_path() could return a draft page instead of a published page when both share the same path. The function now prefers viewable statuses when multiple candidates match the same path.

Details

Includes post_status in the get_page_by_path() query.
Prefers viewable (published) pages over non-viewable (draft) pages when slugs conflict.
Keeps existing behavior of preferring the requested post type over attachments.

Tests

phpunit tests/phpunit/tests/post/getPageByPath.php new regression test added

Trac ticket:
https://core.trac.wordpress.org/ticket/61996

## Use of AI Tools

AI assistance: Yes
Tool(s): Codex
Model(s): GPT-5.2-Codex
Used for: code suggestions and test skeleton; final implementation and tests were reviewed and edited by me, I also performed e2e tests to guarantee the expected result.

This ticket was mentioned in PR #11481 on WordPress/wordpress-develop by @vijendrajat.


5 months ago
#4

Introduces the post_title_child_separator filter in WP_Posts_List_Table::column_title(), allowing the hardcoded em-dash hierarchy indicator to be replaced with a custom string. The separator string is passed through the filter once and then repeated per hierarchy level.

Renames the internal $pad variable to $separator for clarity.

Trac ticket: https://core.trac.wordpress.org/ticket/61996

## Use of AI Tools

This ticket was mentioned in PR #11509 on WordPress/wordpress-develop by @vijendrajat.


5 months ago
#5

Added post_status to the SQL SELECT in get_page_by_path() and replaced the first-match-wins loop with a rank-scoring system using $type_rank (requested type vs attachment) and $status_rank (publish vs everything else), so a published page always wins over a draft sharing the same slug. Also fixed $post_type array handling using in_array() instead of ===.

Trac ticket: https://core.trac.wordpress.org/ticket/61996

## Use of AI Tools

Note: See TracTickets for help on using tickets.