From 89d28916ea5ae0ae7b6f0bcbdf2d1b0179480749 Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Thu, 10 Sep 2026 15:55:59 +0200 Subject: [PATCH 1/3] o2 Posting Access: Check 'edit_post' before an existing post is updated. The posting capabilities this plugin grants are primitive: they say nothing about which post they apply to. A write path that acts on a post ID without asking 'edit_post' about that particular ID therefore reads them as permission over any row in the table. Re-check it on the caller's behalf for users who cannot edit other people's posts, and short-circuit the write when the answer is no. Inserts, editors and administrators, and requests with no current user are untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019QyDZA2WrPWNwcD4qXh5RT --- .../tests/WPorg_O2_Posting_Access_Test.php | 305 ++++++++++++++++++ .../wporg-o2-posting-access.php | 32 ++ 2 files changed, 337 insertions(+) diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php index 48435b1ce1..e2606a9c06 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php @@ -787,4 +787,309 @@ public function test_unrelated_translations_are_untouched() { $this->assertSame( 'Post', $this->plugin->replace_post_button_label( 'Post', 'Post', 'Noun, a post', 'o2' ) ); $this->assertSame( 'Post', $this->plugin->replace_post_button_label( 'Post', 'Post', 'Verb, to post', 'default' ) ); } + + /* + * Writes: the granted capabilities are primitive, so every write path that + * takes a post ID has to be held to 'edit_post' on that specific ID. + */ + + /** + * Seeds a post belonging to somebody else, plus a revision of it. + * + * Seeded with no current user so the pending downgrade does not apply and + * the fixture really is another author's private, published content. + * + * @param string $status Optional. Status for the seeded post. + * @return array{0:int,1:WP_Post} The post ID and its latest revision. + */ + protected function seed_others_post_with_revision( $status = 'private' ) { + $current = get_current_user_id(); + wp_set_current_user( 0 ); + + $author = $this->factory()->user->create( array( 'role' => 'author' ) ); + $post_id = $this->factory()->post->create( + array( + 'post_author' => $author, + 'post_status' => $status, + 'post_title' => 'Embargoed plan', + 'post_content' => 'First draft, with the part that was later removed.', + ) + ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Second draft.', + ) + ); + + $revisions = wp_get_post_revisions( $post_id ); + $revision = array_shift( $revisions ); + + wp_set_current_user( $current ); + + $this->assertInstanceOf( WP_Post::class, $revision, 'The fixture needs a revision to be meaningful.' ); + + return array( $post_id, $revision ); + } + + /** + * Creates a post owned by the non-member to hang foreign objects off. + * + * @return int The new post's ID. + */ + protected function create_own_post() { + return wp_insert_post( + array( + 'post_title' => 'Carrier', + 'post_content' => 'Body.', + 'post_status' => 'publish', + 'post_author' => $this->non_member, + ) + ); + } + + /** + * The reparent itself: a non-member must not be able to take ownership of + * another author's revision by pointing it at a post of their own. + */ + public function test_non_member_cannot_reparent_another_authors_revision() { + list( $post_id, $revision ) = $this->seed_others_post_with_revision(); + $carrier = $this->create_own_post(); + + $result = wp_update_post( + array( + 'ID' => $revision->ID, + 'post_parent' => $carrier, + ) + ); + + $this->assertSame( 0, $result ); + $this->assertSame( $post_id, (int) get_post( $revision->ID )->post_parent ); + } + + /** + * What the reparent would buy: core authorizes a revision read against its + * parent, so a revision that still names its real parent stays unreadable. + */ + public function test_non_member_cannot_read_another_authors_revision() { + list( $post_id, $revision ) = $this->seed_others_post_with_revision(); + $carrier = $this->create_own_post(); + + wp_update_post( + array( + 'ID' => $revision->ID, + 'post_parent' => $carrier, + ) + ); + + foreach ( array( $post_id, $carrier ) as $parent ) { + $request = new WP_REST_Request( 'GET', "/wp/v2/posts/{$parent}/revisions/{$revision->ID}" ); + $request->set_param( 'context', 'edit' ); + + $response = rest_do_request( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), "Revision was readable under parent {$parent}." ); + $this->assertStringNotContainsString( 'later removed', wp_json_encode( $response->get_data() ) ); + } + } + + /** + * Hierarchical content: reparenting somebody else's page also rewrites its + * permalink, so the same check has to cover pages. + */ + public function test_non_member_cannot_reparent_another_authors_page() { + wp_set_current_user( 0 ); + $author = $this->factory()->user->create( array( 'role' => 'editor' ) ); + $section = $this->factory()->post->create( + array( + 'post_type' => 'page', + 'post_author' => $author, + ) + ); + $page_id = $this->factory()->post->create( + array( + 'post_type' => 'page', + 'post_author' => $author, + 'post_parent' => $section, + ) + ); + wp_set_current_user( $this->non_member ); + + $carrier = $this->create_own_post(); + + $result = wp_update_post( + array( + 'ID' => $page_id, + 'post_parent' => $carrier, + ) + ); + + $this->assertSame( 0, $result ); + $this->assertSame( $section, (int) get_post( $page_id )->post_parent ); + } + + /** + * The block is on the object, not on one field: content is protected too. + */ + public function test_non_member_cannot_overwrite_another_authors_post() { + list( $post_id ) = $this->seed_others_post_with_revision( 'publish' ); + + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Overwritten.', + 'post_title' => 'Overwritten.', + ) + ); + + $this->assertSame( 0, $result ); + $this->assertSame( 'Second draft.', get_post( $post_id )->post_content ); + } + + /** + * Attachments are routed through wp_insert_attachment(), a separate entry + * point into the same insert, so they are pinned too. + */ + public function test_non_member_cannot_reparent_another_authors_attachment() { + wp_set_current_user( 0 ); + $author = $this->factory()->user->create( array( 'role' => 'editor' ) ); + $attachment_id = $this->factory()->attachment->create( array( 'post_author' => $author ) ); + wp_set_current_user( $this->non_member ); + + $carrier = $this->create_own_post(); + + wp_update_post( + array( + 'ID' => $attachment_id, + 'post_parent' => $carrier, + ) + ); + + $this->assertNotSame( $carrier, (int) get_post( $attachment_id )->post_parent ); + } + + /** + * The legitimate case the caller exists for: attaching an upload of your own + * to a post of your own has to keep working. + */ + public function test_non_member_can_attach_their_own_attachment_to_their_own_post() { + $carrier = $this->create_own_post(); + $attachment_id = $this->factory()->attachment->create( array( 'post_author' => $this->non_member ) ); + + wp_update_post( + array( + 'ID' => $attachment_id, + 'post_parent' => $carrier, + ) + ); + + $this->assertSame( $carrier, (int) get_post( $attachment_id )->post_parent ); + } + + /** + * Editing your own submission is the whole point of the grant. + */ + public function test_non_member_can_still_update_their_own_post() { + $post_id = $this->create_own_post(); + + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Edited by the author.', + ) + ); + + $this->assertSame( $post_id, $result ); + $this->assertSame( 'Edited by the author.', get_post( $post_id )->post_content ); + } + + /** + * Membership is not the boundary: a role that carries no claim over other + * people's posts gets the same treatment as a non-member. o2's update path + * runs the same attachment cleanup as its create path, so an author editing + * a post of their own reaches it too. + */ + public function test_author_member_cannot_reparent_another_authors_revision() { + list( $post_id, $revision ) = $this->seed_others_post_with_revision(); + + $member = $this->factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $member ); + + $carrier = wp_insert_post( + array( + 'post_title' => 'Member carrier', + 'post_content' => 'Body.', + 'post_status' => 'publish', + 'post_author' => $member, + ) + ); + + $result = wp_update_post( + array( + 'ID' => $revision->ID, + 'post_parent' => $carrier, + ) + ); + + $this->assertSame( 0, $result ); + $this->assertSame( $post_id, (int) get_post( $revision->ID )->post_parent ); + } + + /** + * Members are unaffected: an editor edits other people's posts by design. + */ + public function test_member_can_still_update_another_authors_post() { + list( $post_id ) = $this->seed_others_post_with_revision( 'publish' ); + + $editor = $this->factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor ); + + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Edited by an editor.', + ) + ); + + $this->assertSame( $post_id, $result ); + $this->assertSame( 'Edited by an editor.', get_post( $post_id )->post_content ); + } + + /** + * Cron, WP-CLI and importers run with no current user and must not be caught. + */ + public function test_update_with_no_current_user_is_untouched() { + list( $post_id ) = $this->seed_others_post_with_revision( 'publish' ); + + wp_set_current_user( 0 ); + + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Edited by a script.', + ) + ); + + $this->assertSame( $post_id, $result ); + } + + /** + * A genuinely empty post still reports itself as empty, so the filter's + * original meaning is preserved for everyone else. + */ + public function test_empty_content_is_still_reported_empty() { + $post_id = $this->create_own_post(); + + $this->assertTrue( $this->plugin->restrict_updates_to_editable_posts( true, array( 'ID' => $post_id ) ) ); + $this->assertTrue( $this->plugin->restrict_updates_to_editable_posts( true, array() ) ); + } + + /** + * Creating a post supplies no ID, so nothing about creation changes. + */ + public function test_insert_without_an_id_is_untouched() { + $this->assertFalse( $this->plugin->restrict_updates_to_editable_posts( false, array() ) ); + $this->assertFalse( $this->plugin->restrict_updates_to_editable_posts( false, array( 'ID' => 0 ) ) ); + } } diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php index 0a5aaa401f..6958d52db0 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php @@ -21,6 +21,7 @@ public function init() { } add_filter( 'user_has_cap', [ $this, 'add_post_capabilities' ], 10, 4 ); + add_filter( 'wp_insert_post_empty_content', [ $this, 'restrict_updates_to_editable_posts' ], 10, 2 ); add_action( 'registered_post_type', [ $this, 'restrict_rest_queries' ] ); foreach ( get_post_types() as $post_type ) { @@ -290,6 +291,37 @@ public function add_post_capabilities( $allcaps, $caps, $args, $user ) { return $allcaps; } + /** + * Blocks updates to existing posts the current user cannot edit. + * + * The capabilities added in add_post_capabilities() are primitive capabilities, + * which say nothing about the object they are used on, and the same is true of + * the ones a low-privileged role carries. A caller that acts on a post ID + * without asking 'edit_post' about that specific ID therefore reads them as + * permission over every row in the table. Ask on the caller's behalf, and let + * the write short-circuit when the answer is no. + * + * Users who may edit other people's posts are left alone, as are inserts: a row + * that does not exist yet has nobody to take it from. + * + * @param bool $maybe_empty Whether the post should be considered "empty". + * @param array $postarr Array of post data. + * @return bool Filtered value. + */ + public function restrict_updates_to_editable_posts( $maybe_empty, $postarr ) { + if ( $maybe_empty ) { + return $maybe_empty; + } + + $post_id = (int) ( $postarr['ID'] ?? 0 ); + + if ( ! $post_id || ! get_current_user_id() || current_user_can( 'edit_others_posts' ) ) { + return $maybe_empty; + } + + return ! current_user_can( 'edit_post', $post_id ); + } + /** * Restricts REST collection queries for a post type. * From 0b4a16697200babd00c677cec33e886c30e0d3d9 Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Thu, 10 Sep 2026 16:28:31 +0200 Subject: [PATCH 2/3] o2 Posting Access: Ask the post type which 'edit others' capability it means. 'edit_others_posts' is only the generic name for that capability. A post type can name its own, and on Make sites some do, so exempting on the generic name let somebody past the object check on a type whose capability they were never given. Read the capability off the target post's own type, resolving a revision to its parent the way map_meta_cap() does, and fall back to the generic name only when the type cannot be resolved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019QyDZA2WrPWNwcD4qXh5RT --- .../tests/WPorg_O2_Posting_Access_Test.php | 96 +++++++++++++++++++ .../wporg-o2-posting-access.php | 22 ++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php index e2606a9c06..c4890f2243 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php @@ -1056,6 +1056,102 @@ public function test_member_can_still_update_another_authors_post() { $this->assertSame( 'Edited by an editor.', get_post( $post_id )->post_content ); } + /** + * 'edit_others_posts' is only the generic name for the capability. A post type + * can name its own, so the exemption asks the target post type which one it + * means. Somebody who may edit other people's posts is still held to the object + * check on a type whose capability they were never given. + */ + public function test_generic_others_capability_does_not_exempt_a_custom_post_type() { + register_post_type( + 'wporg_capped_cpt', + array( + 'public' => true, + 'map_meta_cap' => true, + 'capability_type' => array( 'wporg_test_capped', 'wporg_test_cappeds' ), + ) + ); + + wp_set_current_user( 0 ); + $author = $this->factory()->user->create( array( 'role' => 'author' ) ); + $post_id = $this->factory()->post->create( + array( + 'post_type' => 'wporg_capped_cpt', + 'post_author' => $author, + 'post_content' => 'Original.', + ) + ); + + $editor = $this->factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor ); + + $has_generic_cap = current_user_can( 'edit_others_posts' ); + $can_edit_object = current_user_can( 'edit_post', $post_id ); + + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Overwritten.', + ) + ); + + unregister_post_type( 'wporg_capped_cpt' ); + + $this->assertTrue( $has_generic_cap, 'The fixture needs the generic capability for this test to mean anything.' ); + $this->assertFalse( $can_edit_object, 'The fixture needs the object check to fail for this test to mean anything.' ); + $this->assertSame( 0, $result ); + $this->assertSame( 'Original.', get_post( $post_id )->post_content ); + } + + /** + * The other half of that: holding the post type's own capability exempts the + * update, so a handbook editor keeps working on handbook pages. + */ + public function test_post_type_capability_exempts_the_update() { + register_post_type( + 'wporg_capped_cpt', + array( + 'public' => true, + 'map_meta_cap' => true, + 'capability_type' => array( 'wporg_test_capped', 'wporg_test_cappeds' ), + ) + ); + + wp_set_current_user( 0 ); + $author = $this->factory()->user->create( array( 'role' => 'author' ) ); + $post_id = $this->factory()->post->create( + array( + 'post_type' => 'wporg_capped_cpt', + 'post_author' => $author, + 'post_content' => 'Original.', + ) + ); + + $editor = $this->factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor ); + + $grant = function ( $caps ) { + $caps['edit_others_wporg_test_cappeds'] = true; + $caps['edit_published_wporg_test_cappeds'] = true; + + return $caps; + }; + add_filter( 'user_has_cap', $grant ); + + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Edited by a handbook editor.', + ) + ); + + remove_filter( 'user_has_cap', $grant ); + unregister_post_type( 'wporg_capped_cpt' ); + + $this->assertSame( $post_id, $result ); + $this->assertSame( 'Edited by a handbook editor.', get_post( $post_id )->post_content ); + } + /** * Cron, WP-CLI and importers run with no current user and must not be caught. */ diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php index 6958d52db0..ede01d26a8 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php @@ -301,8 +301,10 @@ public function add_post_capabilities( $allcaps, $caps, $args, $user ) { * permission over every row in the table. Ask on the caller's behalf, and let * the write short-circuit when the answer is no. * - * Users who may edit other people's posts are left alone, as are inserts: a row - * that does not exist yet has nobody to take it from. + * Users who may edit other people's posts of that type are left alone, as are + * inserts: a row that does not exist yet has nobody to take it from. The type + * is read from the stored row rather than from the incoming data, and a + * revision defers to its parent, which is how map_meta_cap() reads them too. * * @param bool $maybe_empty Whether the post should be considered "empty". * @param array $postarr Array of post data. @@ -315,7 +317,21 @@ public function restrict_updates_to_editable_posts( $maybe_empty, $postarr ) { $post_id = (int) ( $postarr['ID'] ?? 0 ); - if ( ! $post_id || ! get_current_user_id() || current_user_can( 'edit_others_posts' ) ) { + if ( ! $post_id || ! get_current_user_id() ) { + return $maybe_empty; + } + + $post = get_post( $post_id ); + if ( $post && 'revision' === $post->post_type ) { + $post = get_post( $post->post_parent ); + } + + $post_type = $post ? get_post_type_object( $post->post_type ) : null; + + // A post type names its own capability, and it is not always the generic one. + $edit_others = $post_type ? $post_type->cap->edit_others_posts : 'edit_others_posts'; + + if ( current_user_can( $edit_others ) ) { return $maybe_empty; } From 625fe52405289a16ee6a6256e55ea5f4bb8a3d5e Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Thu, 10 Sep 2026 16:44:36 +0200 Subject: [PATCH 3/3] o2 Posting Access: Hold every update to the object check, not to a capability name. Exempting on a capability name was coarser than 'edit_post' itself: whoever held the name skipped the status-dependent half of the object check, so somebody with 'edit_others_posts' and no 'edit_private_posts' could still be let through to another author's private post. Ask about the object every time instead. The one exemption left is structural rather than a judgement call. A post type that does not map meta capabilities answers 'edit_post' with a primitive no role is granted, so the question denies a site administrator too. Core's custom_css is registered that way and the Customizer saves Additional CSS through wp_update_post(), which the tests now pin. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019QyDZA2WrPWNwcD4qXh5RT --- .../tests/WPorg_O2_Posting_Access_Test.php | 97 +++++++++++++++++-- .../wporg-o2-posting-access.php | 21 ++-- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php index c4890f2243..50f84cbfc7 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/phpunit/tests/WPorg_O2_Posting_Access_Test.php @@ -1057,10 +1057,10 @@ public function test_member_can_still_update_another_authors_post() { } /** - * 'edit_others_posts' is only the generic name for the capability. A post type - * can name its own, so the exemption asks the target post type which one it - * means. Somebody who may edit other people's posts is still held to the object - * check on a type whose capability they were never given. + * 'edit_others_posts' is only the generic name for the capability, and a post + * type can name its own. Somebody who may edit other people's posts is still + * refused on a type whose capability they were never given, because the check + * asks about the object rather than about a capability name. */ public function test_generic_others_capability_does_not_exempt_a_custom_post_type() { register_post_type( @@ -1104,10 +1104,10 @@ public function test_generic_others_capability_does_not_exempt_a_custom_post_typ } /** - * The other half of that: holding the post type's own capability exempts the - * update, so a handbook editor keeps working on handbook pages. + * The other half of that: holding the post type's own capabilities satisfies + * the object check, so a handbook editor keeps working on handbook pages. */ - public function test_post_type_capability_exempts_the_update() { + public function test_post_type_capability_permits_the_update() { register_post_type( 'wporg_capped_cpt', array( @@ -1152,6 +1152,89 @@ public function test_post_type_capability_exempts_the_update() { $this->assertSame( 'Edited by a handbook editor.', get_post( $post_id )->post_content ); } + /** + * The status half of the same point: another author's private post needs + * 'edit_private_posts' on top of 'edit_others_posts', and the object check + * asks for both because map_meta_cap() does. + */ + public function test_others_capability_alone_does_not_reach_a_private_post() { + wp_set_current_user( 0 ); + $author = $this->factory()->user->create( array( 'role' => 'author' ) ); + $post_id = $this->factory()->post->create( + array( + 'post_author' => $author, + 'post_status' => 'private', + 'post_content' => 'Original.', + ) + ); + + $user = $this->factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user ); + + // A role holding 'edit_others_posts' without 'edit_private_posts'. + $grant = function ( $caps ) { + $caps['edit_others_posts'] = true; + $caps['edit_published_posts'] = true; + + return $caps; + }; + add_filter( 'user_has_cap', $grant ); + + $has_others_cap = current_user_can( 'edit_others_posts' ); + $can_edit_object = current_user_can( 'edit_post', $post_id ); + + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Overwritten.', + ) + ); + + remove_filter( 'user_has_cap', $grant ); + + $this->assertTrue( $has_others_cap, 'The fixture needs the others capability for this test to mean anything.' ); + $this->assertFalse( $can_edit_object, 'The fixture needs the object check to fail for this test to mean anything.' ); + $this->assertSame( 0, $result ); + $this->assertSame( 'Original.', get_post( $post_id )->post_content ); + } + + /** + * Core registers custom_css without map_meta_cap, so its 'edit_post' resolves + * to a primitive no role is granted and the question denies even a site + * administrator. The Customizer saves Additional CSS through wp_update_post(), + * and the stored post belongs to whoever saved it first, so asking would break + * that for the next administrator to touch it. + */ + public function test_post_type_without_meta_capability_mapping_is_untouched() { + wp_set_current_user( 0 ); + $first_admin = $this->factory()->user->create( array( 'role' => 'administrator' ) ); + $css_id = $this->factory()->post->create( + array( + 'post_type' => 'custom_css', + 'post_status' => 'publish', + 'post_title' => 'wporg-test-theme', + 'post_author' => $first_admin, + 'post_content' => 'body { color: #000; }', + ) + ); + + $admin = $this->factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin ); + + $can_edit_object = current_user_can( 'edit_post', $css_id ); + + $result = wp_update_post( + array( + 'ID' => $css_id, + 'post_content' => 'body { color: #fff; }', + ) + ); + + $this->assertFalse( $can_edit_object, 'This test is about a type whose edit_post check denies even an administrator.' ); + $this->assertSame( $css_id, $result ); + $this->assertSame( 'body { color: #fff; }', get_post( $css_id )->post_content ); + } + /** * Cron, WP-CLI and importers run with no current user and must not be caught. */ diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php index ede01d26a8..28e3420c98 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-o2-posting-access/wporg-o2-posting-access.php @@ -301,10 +301,10 @@ public function add_post_capabilities( $allcaps, $caps, $args, $user ) { * permission over every row in the table. Ask on the caller's behalf, and let * the write short-circuit when the answer is no. * - * Users who may edit other people's posts of that type are left alone, as are - * inserts: a row that does not exist yet has nobody to take it from. The type - * is read from the stored row rather than from the incoming data, and a - * revision defers to its parent, which is how map_meta_cap() reads them too. + * The post type is read from the stored row rather than from the incoming data, + * and a revision defers to its parent, which is how map_meta_cap() reads them + * too. Inserts are untouched: a row that does not exist yet has nobody to take + * it from. * * @param bool $maybe_empty Whether the post should be considered "empty". * @param array $postarr Array of post data. @@ -328,10 +328,15 @@ public function restrict_updates_to_editable_posts( $maybe_empty, $postarr ) { $post_type = $post ? get_post_type_object( $post->post_type ) : null; - // A post type names its own capability, and it is not always the generic one. - $edit_others = $post_type ? $post_type->cap->edit_others_posts : 'edit_others_posts'; - - if ( current_user_can( $edit_others ) ) { + /* + * A post type that does not map meta capabilities answers 'edit_post' with + * a primitive of its own that no role is granted, so the question denies a + * site administrator as readily as anybody else. Core's custom_css is one + * of those, and the Customizer saves Additional CSS through wp_update_post(), + * so asking would break it. Authorization for those types is whatever the + * code registering them decided it is. + */ + if ( ! $post_type || ! $post_type->map_meta_cap ) { return $maybe_empty; }