Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion assets/js/suggested-task.js
Original file line number Diff line number Diff line change
Expand Up @@ -472,11 +472,13 @@ prplSuggestedTask = {
} )
)
);
// Use textContent (not innerHTML) so a title typed into the
// contenteditable field cannot inject markup.
el
.closest( 'li.prpl-suggested-task' )
.querySelector(
'label:has(.prpl-suggested-task-checkbox) .screen-reader-text'
).innerHTML = `${ title }: ${ prplL10n( 'markAsComplete' ) }`;
).textContent = `${ title }: ${ prplL10n( 'markAsComplete' ) }`;
}, 300 );
},

Expand Down
25 changes: 25 additions & 0 deletions classes/class-suggested-tasks.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ public function __construct() {
// Filter the REST API response.
\add_filter( 'rest_prepare_prpl_recommendations', [ $this, 'rest_prepare_recommendation' ], 10, 2 );

// Sanitize the recommendation title on insert/update via the REST API, to prevent stored XSS.
\add_filter( 'rest_pre_insert_prpl_recommendations', [ $this, 'rest_sanitize_recommendation' ], 10, 2 );

\add_filter( 'wp_trash_post_days', [ $this, 'change_trashed_posts_lifetime' ], 10, 2 );
}

Expand Down Expand Up @@ -505,6 +508,28 @@ public function rest_api_tax_query( $args, $request ) {
return $args;
}

/**
* Sanitize a recommendation before it is inserted or updated via the REST API.
*
* Recommendation titles are plain text (they are rendered unescaped in JS
* templates such as views/js-templates/suggested-task.html), so we strip any
* HTML tags here to prevent stored XSS. This runs regardless of the user's
* `unfiltered_html` capability, which WordPress would otherwise honor for the
* post title.
*
* @param \stdClass $prepared_post An object representing a single post prepared for inserting or updating the database.
* @param \WP_REST_Request $request The request object.
*
* @return \stdClass The sanitized post object.
*/
public function rest_sanitize_recommendation( $prepared_post, $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( isset( $prepared_post->post_title ) ) {
$prepared_post->post_title = \sanitize_text_field( \wp_strip_all_tags( $prepared_post->post_title ) );
}

return $prepared_post;
}

/**
* Filter the REST API response.
*
Expand Down
63 changes: 63 additions & 0 deletions classes/update/class-update-191.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
/**
* Update class for version 1.9.1.
*
* @package Progress_Planner
*/

namespace Progress_Planner\Update;

/**
* Update class for version 1.9.1.
*
* @package Progress_Planner
*/
class Update_191 {

const VERSION = '1.9.1';

/**
* Run the update.
*
* @return void
*/
public function run() {
$this->sanitize_recommendation_titles();
}

/**
* Strip HTML tags from existing recommendation titles.
*
* Titles are now sanitized on write, but rows stored before that may still
* contain markup. This cleans them up.
*
* @return void
*/
private function sanitize_recommendation_titles() {
$db = \progress_planner()->get_suggested_tasks_db();

// get() defaults to all relevant statuses (publish, trash, draft, future, pending).
$recommendations = $db->get();

foreach ( $recommendations as $recommendation ) {
$sanitized = \sanitize_text_field( \wp_strip_all_tags( $recommendation->post_title ) );

// Nothing to do if stripping didn't change the title.
if ( $sanitized === $recommendation->post_title ) {
continue;
}

// A title that was pure markup strips to an empty string. The plugin
// never stores title-less recommendations (Suggested_Tasks_DB::add()
// rejects them), so such a row is junk - delete it. This also avoids
// WordPress's empty-content guard, which would otherwise reject an
// update that leaves the title empty and leave the markup in place.
if ( '' === $sanitized ) {
$db->delete_recommendation( (int) $recommendation->ID );
continue;
}

$db->update_recommendation( $recommendation->ID, [ 'post_title' => $sanitized ] );
}
}
}
8 changes: 5 additions & 3 deletions classes/utils/class-debug-tools.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,11 @@ protected function add_suggested_tasks_submenu_item( $admin_bar ) {

$admin_bar->add_node(
[
'id' => 'prpl-suggested-' . $key . '-' . $title,
// Use the post ID (not the title) for the node ID, and escape the
// title for display - the admin bar renders 'title' as raw HTML.
'id' => 'prpl-suggested-' . $key . '-' . $task->ID,
'parent' => 'prpl-suggested-' . $key,
'title' => $title . ' <a href="' . \esc_url( $delete_url ) . '" style="color: #dc3232; display: inline-block; margin-left: 5px; text-decoration: none;">×</a>',
'title' => \esc_html( $title ) . ' <a href="' . \esc_url( $delete_url ) . '" style="color: #dc3232; display: inline-block; margin-left: 5px; text-decoration: none;">×</a>',
]
);
}
Expand Down Expand Up @@ -294,7 +296,7 @@ protected function add_activities_submenu_item( $admin_bar ) {
[
'id' => 'prpl-activity-' . $activity->id,
'parent' => 'prpl-activities',
'title' => $activity->data_id . ' - ' . $activity->category,
'title' => \esc_html( $activity->data_id . ' - ' . $activity->category ),
]
);
}
Expand Down
Loading
Loading