Skip to content

Commit fcf9628

Browse files
Merge pull request #508 from Yoast/claude/cve-2026-53739-53740-fix-b9qdjt
Fix CVE-2026-53739 (CSRF) & CVE-2026-53740 (XSS)
2 parents 9402562 + ea9fbc2 commit fcf9628

4 files changed

Lines changed: 164 additions & 3 deletions

File tree

admin-functions.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,13 @@ function duplicate_post_show_update_notice() {
241241
. '</div>
242242
</div>';
243243

244+
$dismiss_nonce = wp_create_nonce( 'duplicate_post_dismiss_notice' );
245+
244246
echo "<script>
245247
function duplicate_post_dismiss_notice(){
246248
var data = {
247249
'action': 'duplicate_post_dismiss_notice',
250+
'nonce': '" . esc_js( $dismiss_nonce ) . "',
248251
};
249252
250253
jQuery.post(ajaxurl, data, function(response) {
@@ -266,6 +269,14 @@ function duplicate_post_dismiss_notice(){
266269
* @return bool
267270
*/
268271
function duplicate_post_dismiss_notice() {
272+
if ( ! current_user_can( 'manage_options' ) ) {
273+
return false;
274+
}
275+
276+
if ( ! check_ajax_referer( 'duplicate_post_dismiss_notice', 'nonce', false ) ) {
277+
return false;
278+
}
279+
269280
return update_site_option( 'duplicate_post_show_notice', 0 );
270281
}
271282

src/ui/classic-editor.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ public function change_scheduled_notice_classic_editor( $messages ) {
269269
return $messages;
270270
}
271271

272-
$permalink = \get_permalink( $post->ID );
272+
$permalink = \esc_url( \get_permalink( $post->ID ) );
273+
$title = \esc_html( $post->post_title );
273274
$scheduled_date = \get_the_time( \get_option( 'date_format' ), $post );
274275
$scheduled_time = \get_the_time( \get_option( 'time_format' ), $post );
275276

@@ -280,7 +281,7 @@ public function change_scheduled_notice_classic_editor( $messages ) {
280281
'This rewritten post %1$s is now scheduled to replace the original post. It will be published on %2$s.',
281282
'duplicate-post',
282283
),
283-
'<a href="' . $permalink . '">' . $post->post_title . '</a>',
284+
'<a href="' . $permalink . '">' . $title . '</a>',
284285
'<strong>' . $scheduled_date . ' ' . $scheduled_time . '</strong>',
285286
);
286287
return $messages;
@@ -293,7 +294,7 @@ public function change_scheduled_notice_classic_editor( $messages ) {
293294
'This rewritten page %1$s is now scheduled to replace the original page. It will be published on %2$s.',
294295
'duplicate-post',
295296
),
296-
'<a href="' . $permalink . '">' . $post->post_title . '</a>',
297+
'<a href="' . $permalink . '">' . $title . '</a>',
297298
'<strong>' . $scheduled_date . ' ' . $scheduled_time . '</strong>',
298299
);
299300
}

tests/Unit/UI/Classic_Editor_Test.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,7 @@ public function test_should_not_change_schedule_strings_other_context() {
820820
*/
821821
public function test_should_change_scheduled_notice_post() {
822822
$this->stubTranslationFunctions();
823+
$this->stubEscapeFunctions();
823824

824825
$post = Mockery::mock( WP_Post::class );
825826
$post->post_type = 'post';
@@ -936,6 +937,7 @@ public function test_should_change_scheduled_notice_post() {
936937
*/
937938
public function test_should_change_scheduled_notice_page() {
938939
$this->stubTranslationFunctions();
940+
$this->stubEscapeFunctions();
939941

940942
$post = Mockery::mock( WP_Post::class );
941943
$post->post_type = 'page';
@@ -1043,6 +1045,80 @@ public function test_should_change_scheduled_notice_page() {
10431045
$this->assertSame( $result, $this->instance->change_scheduled_notice_classic_editor( $messages ) );
10441046
}
10451047

1048+
/**
1049+
* Tests that change_scheduled_notice_classic_editor escapes a malicious post title (CVE-2026-53740).
1050+
*
1051+
* @covers \Yoast\WP\Duplicate_Post\UI\Classic_Editor::change_scheduled_notice_classic_editor
1052+
*
1053+
* @return void
1054+
*/
1055+
public function test_change_scheduled_notice_escapes_malicious_title() {
1056+
$this->stubTranslationFunctions();
1057+
1058+
Monkey\Functions\when( '\esc_html' )->alias(
1059+
static function ( $text ) {
1060+
return \htmlspecialchars( (string) $text, \ENT_QUOTES, 'UTF-8' );
1061+
},
1062+
);
1063+
Monkey\Functions\when( '\esc_url' )->alias(
1064+
static function ( $url ) {
1065+
return \htmlspecialchars( (string) $url, \ENT_QUOTES, 'UTF-8' );
1066+
},
1067+
);
1068+
1069+
$post = Mockery::mock( WP_Post::class );
1070+
$post->post_type = 'post';
1071+
$post->post_title = '</a><script>alert(1)</script>';
1072+
$post->ID = 1;
1073+
1074+
$permalink = 'http://basic.wordpress.test/example_post';
1075+
$date_format = 'F j, Y';
1076+
$scheduled_date = 'December 18, 2020';
1077+
$time_format = 'g:i a';
1078+
$scheduled_time = '2:30 pm';
1079+
1080+
Monkey\Functions\expect( '\get_post' )
1081+
->once()
1082+
->andReturn( $post );
1083+
1084+
$this->instance->expects( 'should_change_rewrite_republish_copy' )
1085+
->with( $post )
1086+
->once()
1087+
->andReturnTrue();
1088+
1089+
Monkey\Functions\expect( '\get_permalink' )
1090+
->once()
1091+
->with( $post->ID )
1092+
->andReturn( $permalink );
1093+
1094+
Monkey\Functions\expect( '\get_option' )
1095+
->once()
1096+
->with( 'date_format' )
1097+
->andReturn( $date_format );
1098+
1099+
Monkey\Functions\expect( '\get_option' )
1100+
->once()
1101+
->with( 'time_format' )
1102+
->andReturn( $time_format );
1103+
1104+
Monkey\Functions\expect( '\get_the_time' )
1105+
->once()
1106+
->with( $date_format, $post )
1107+
->andReturn( $scheduled_date );
1108+
1109+
Monkey\Functions\expect( '\get_the_time' )
1110+
->once()
1111+
->with( $time_format, $post )
1112+
->andReturn( $scheduled_time );
1113+
1114+
$result = $this->instance->change_scheduled_notice_classic_editor( [] );
1115+
1116+
$expected_link = '<a href="' . $permalink . '">&lt;/a&gt;&lt;script&gt;alert(1)&lt;/script&gt;</a>';
1117+
1118+
$this->assertStringContainsString( $expected_link, $result['post'][9] );
1119+
$this->assertStringNotContainsString( '<script>', $result['post'][9] );
1120+
}
1121+
10461122
/**
10471123
* Tests the should_change_rewrite_republish_copy function when it should return true for a post.
10481124
*

tests/WP/Admin_Functions_Test.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,4 +1262,77 @@ public function test_create_duplicate_copies_thumbnail_when_enabled() {
12621262
// Thumbnail SHOULD be copied when enabled.
12631263
$this->assertSame( $attachment_id, (int) \get_post_meta( $new_id, '_thumbnail_id', true ) );
12641264
}
1265+
1266+
/**
1267+
* Tests that duplicate_post_dismiss_notice() dismisses the notice for an authorized user with a valid nonce.
1268+
*
1269+
* @covers ::duplicate_post_dismiss_notice
1270+
*
1271+
* @return void
1272+
*/
1273+
public function test_dismiss_notice_succeeds_with_valid_nonce_and_capability() {
1274+
\update_site_option( 'duplicate_post_show_notice', 1 );
1275+
1276+
// The set_up() already logs in an administrator (has manage_options).
1277+
$_REQUEST['nonce'] = \wp_create_nonce( 'duplicate_post_dismiss_notice' );
1278+
1279+
$result = \duplicate_post_dismiss_notice();
1280+
1281+
$this->assertTrue( $result );
1282+
$this->assertSame( 0, (int) \get_site_option( 'duplicate_post_show_notice' ) );
1283+
1284+
// Clean up.
1285+
unset( $_REQUEST['nonce'] );
1286+
\delete_site_option( 'duplicate_post_show_notice' );
1287+
}
1288+
1289+
/**
1290+
* Tests that duplicate_post_dismiss_notice() does nothing for a user without the manage_options capability.
1291+
*
1292+
* @covers ::duplicate_post_dismiss_notice
1293+
*
1294+
* @return void
1295+
*/
1296+
public function test_dismiss_notice_fails_without_capability() {
1297+
\update_site_option( 'duplicate_post_show_notice', 1 );
1298+
1299+
// Switch to a subscriber, who does not have manage_options.
1300+
$subscriber_id = $this->factory->user->create( [ 'role' => 'subscriber' ] );
1301+
\wp_set_current_user( $subscriber_id );
1302+
1303+
// Even with a valid nonce the capability check must block the action.
1304+
$_REQUEST['nonce'] = \wp_create_nonce( 'duplicate_post_dismiss_notice' );
1305+
1306+
$result = \duplicate_post_dismiss_notice();
1307+
1308+
$this->assertFalse( $result );
1309+
$this->assertSame( 1, (int) \get_site_option( 'duplicate_post_show_notice' ) );
1310+
1311+
// Clean up.
1312+
unset( $_REQUEST['nonce'] );
1313+
\delete_site_option( 'duplicate_post_show_notice' );
1314+
}
1315+
1316+
/**
1317+
* Tests that duplicate_post_dismiss_notice() does nothing when the nonce is missing or invalid.
1318+
*
1319+
* @covers ::duplicate_post_dismiss_notice
1320+
*
1321+
* @return void
1322+
*/
1323+
public function test_dismiss_notice_fails_with_invalid_nonce() {
1324+
\update_site_option( 'duplicate_post_show_notice', 1 );
1325+
1326+
// The set_up() already logs in an administrator (has manage_options).
1327+
$_REQUEST['nonce'] = 'invalid-nonce';
1328+
1329+
$result = \duplicate_post_dismiss_notice();
1330+
1331+
$this->assertFalse( $result );
1332+
$this->assertSame( 1, (int) \get_site_option( 'duplicate_post_show_notice' ) );
1333+
1334+
// Clean up.
1335+
unset( $_REQUEST['nonce'] );
1336+
\delete_site_option( 'duplicate_post_show_notice' );
1337+
}
12651338
}

0 commit comments

Comments
 (0)