Skip to content

Commit 35019f9

Browse files
ilicfilipjdevalkclaude
authored
Sync v1.9.1 into develop + clear dev-dependency CVEs (#765)
* Fix PHPStan errors and phpunit CVE on main Brings main's static analysis and dependency security checks back to green: - Static Analysis: clear 25 pre-existing PHPStan errors. Ports develop's typed @return on Date::get_periods()/get_range() (which also resolves the Chart modify() errors), takes develop's exact versions of class-page-settings, class-activity-scores, class-chart and class-update-140, converts the WP-core require_once ignores to the @phpstan-ignore-next-line form that suppresses under PHPStan 2.1.x, and adds inline ignores elsewhere. - Security check: bump phpunit/phpunit 9.6.30 -> 9.6.34 in composer.lock to resolve CVE-2026-24765 (unsafe deserialization in PHPT code coverage). * Fix abstract method fatal in test-class-security.php The anonymous classes extending the abstract Tasks_Interactive did not implement the abstract Tasks::should_add_task() method. phpunit 9.6.30 did not surface this, but 9.6.34 (the CVE-2026-24765 fix) does, causing a fatal when the test class loads. Implement should_add_task() in all 8 anonymous task providers. * v1.9.1 (#763) * Sanitize and escape prpl_recommendations title An authenticated Editor (or higher) could create a recommendation via POST /wp/v2/prpl_recommendations with an HTML payload in the `title` field (e.g. `<img src=x onerror=alert(1)>`). The dashboard JS template (views/js-templates/suggested-task.html) renders `title.rendered` with Underscore's unescaped `{{{ }}}` syntax, so the payload executed when an admin loaded the dashboard. Defense in depth: - Input: add a `rest_pre_insert_prpl_recommendations` filter that strips tags from `post_title` on every REST insert/update, regardless of the user's `unfiltered_html` capability. Recommendation titles are plain text, so this neutralizes the payload at the source. - Output (JS): route the two raw `{{{ }}}` title sinks through a new `prplSuggestedTask.sanitizeTitle()` helper, which inert-parses the value with DOMParser (no script/resource side effects) and re-escapes it, preserving legitimate entities like `&amp;` without double-encoding the server-side `esc_html`'d provider titles. - Output (admin bar): the PRPL debug tool printed `post_title` unescaped into a `WP_Admin_Bar` node id (an HTML attribute) and title (rendered as raw HTML), firing the payload on every admin page in debug mode. Escape the title with `esc_html()`, use the post ID for the node id, and escape the activities node title too. - Also switch `updateTaskTitle` to set `.textContent` instead of `.innerHTML` for the screen-reader label, closing a self-XSS sink. Adds tests/phpunit/test-class-rest-recommendations-xss.php covering Editor and Administrator payloads plus a plain-text regression check. * Bump version to 1.9.1 * add migration script and revert JS title escaping * add inline comment, cc @tacoverdo * Delete recommendation when sanitized title is empty A title that is pure markup strips to an empty string. wp_update_post() rejects an update that would leave the title, content, and excerpt all empty, so the malicious title was left in the DB. The plugin never stores title-less recommendations, so delete such rows instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * update readme.txt --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix plain-text title test to pass on multisite On multisite, editors lack the unfiltered_html capability, so core's kses encodes the ampersand in the test title and the byte-for-byte assertion fails. Grant the capability (via super admin on multisite) so the test isolates our XSS sanitization rather than core's kses behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Grant unfiltered_html before switching user in title test kses_init() runs on the set_current_user hook and decides whether to attach the kses filters at switch time. The capability must be granted before wp_set_current_user(), otherwise the filters are already attached and the multisite assertion still sees the ampersand encoded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Bump composer/composer 2.9.2 -> 2.10.0 to clear dev-dependency CVEs Resolves the Security check failure: composer/composer 2.9.2 (pulled in transitively via wp-cli/wp-cli-bundle in require-dev) carried CVE-2026-40176, CVE-2026-40261, and CVE-2026-45793. Targeted `composer update composer/composer --with-dependencies`; composer.json (runtime deps) unchanged. `composer audit` now reports no advisories. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Joost de Valk <joost@altha.nl> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a15bf2d commit 35019f9

11 files changed

Lines changed: 628 additions & 157 deletions

assets/js/suggested-task.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,11 +472,13 @@ prplSuggestedTask = {
472472
} )
473473
)
474474
);
475+
// Use textContent (not innerHTML) so a title typed into the
476+
// contenteditable field cannot inject markup.
475477
el
476478
.closest( 'li.prpl-suggested-task' )
477479
.querySelector(
478480
'label:has(.prpl-suggested-task-checkbox) .screen-reader-text'
479-
).innerHTML = `${ title }: ${ prplL10n( 'markAsComplete' ) }`;
481+
).textContent = `${ title }: ${ prplL10n( 'markAsComplete' ) }`;
480482
}, 300 );
481483
},
482484

classes/class-suggested-tasks.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ public function __construct() {
6767
// Filter the REST API response.
6868
\add_filter( 'rest_prepare_prpl_recommendations', [ $this, 'rest_prepare_recommendation' ], 10, 2 );
6969

70+
// Sanitize the recommendation title on insert/update via the REST API, to prevent stored XSS.
71+
\add_filter( 'rest_pre_insert_prpl_recommendations', [ $this, 'rest_sanitize_recommendation' ], 10, 2 );
72+
7073
\add_filter( 'wp_trash_post_days', [ $this, 'change_trashed_posts_lifetime' ], 10, 2 );
7174
}
7275

@@ -505,6 +508,28 @@ public function rest_api_tax_query( $args, $request ) {
505508
return $args;
506509
}
507510

511+
/**
512+
* Sanitize a recommendation before it is inserted or updated via the REST API.
513+
*
514+
* Recommendation titles are plain text (they are rendered unescaped in JS
515+
* templates such as views/js-templates/suggested-task.html), so we strip any
516+
* HTML tags here to prevent stored XSS. This runs regardless of the user's
517+
* `unfiltered_html` capability, which WordPress would otherwise honor for the
518+
* post title.
519+
*
520+
* @param \stdClass $prepared_post An object representing a single post prepared for inserting or updating the database.
521+
* @param \WP_REST_Request $request The request object.
522+
*
523+
* @return \stdClass The sanitized post object.
524+
*/
525+
public function rest_sanitize_recommendation( $prepared_post, $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
526+
if ( isset( $prepared_post->post_title ) ) {
527+
$prepared_post->post_title = \sanitize_text_field( \wp_strip_all_tags( $prepared_post->post_title ) );
528+
}
529+
530+
return $prepared_post;
531+
}
532+
508533
/**
509534
* Filter the REST API response.
510535
*
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
/**
3+
* Update class for version 1.9.1.
4+
*
5+
* @package Progress_Planner
6+
*/
7+
8+
namespace Progress_Planner\Update;
9+
10+
/**
11+
* Update class for version 1.9.1.
12+
*
13+
* @package Progress_Planner
14+
*/
15+
class Update_191 {
16+
17+
const VERSION = '1.9.1';
18+
19+
/**
20+
* Run the update.
21+
*
22+
* @return void
23+
*/
24+
public function run() {
25+
$this->sanitize_recommendation_titles();
26+
}
27+
28+
/**
29+
* Strip HTML tags from existing recommendation titles.
30+
*
31+
* Titles are now sanitized on write, but rows stored before that may still
32+
* contain markup. This cleans them up.
33+
*
34+
* @return void
35+
*/
36+
private function sanitize_recommendation_titles() {
37+
$db = \progress_planner()->get_suggested_tasks_db();
38+
39+
// get() defaults to all relevant statuses (publish, trash, draft, future, pending).
40+
$recommendations = $db->get();
41+
42+
foreach ( $recommendations as $recommendation ) {
43+
$sanitized = \sanitize_text_field( \wp_strip_all_tags( $recommendation->post_title ) );
44+
45+
// Nothing to do if stripping didn't change the title.
46+
if ( $sanitized === $recommendation->post_title ) {
47+
continue;
48+
}
49+
50+
// A title that was pure markup strips to an empty string. The plugin
51+
// never stores title-less recommendations (Suggested_Tasks_DB::add()
52+
// rejects them), so such a row is junk - delete it. This also avoids
53+
// WordPress's empty-content guard, which would otherwise reject an
54+
// update that leaves the title empty and leave the markup in place.
55+
if ( '' === $sanitized ) {
56+
$db->delete_recommendation( (int) $recommendation->ID );
57+
continue;
58+
}
59+
60+
$db->update_recommendation( $recommendation->ID, [ 'post_title' => $sanitized ] );
61+
}
62+
}
63+
}

classes/utils/class-debug-tools.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,11 @@ protected function add_suggested_tasks_submenu_item( $admin_bar ) {
260260

261261
$admin_bar->add_node(
262262
[
263-
'id' => 'prpl-suggested-' . $key . '-' . $title,
263+
// Use the post ID (not the title) for the node ID, and escape the
264+
// title for display - the admin bar renders 'title' as raw HTML.
265+
'id' => 'prpl-suggested-' . $key . '-' . $task->ID,
264266
'parent' => 'prpl-suggested-' . $key,
265-
'title' => $title . ' <a href="' . \esc_url( $delete_url ) . '" style="color: #dc3232; display: inline-block; margin-left: 5px; text-decoration: none;">×</a>',
267+
'title' => \esc_html( $title ) . ' <a href="' . \esc_url( $delete_url ) . '" style="color: #dc3232; display: inline-block; margin-left: 5px; text-decoration: none;">×</a>',
266268
]
267269
);
268270
}
@@ -294,7 +296,7 @@ protected function add_activities_submenu_item( $admin_bar ) {
294296
[
295297
'id' => 'prpl-activity-' . $activity->id,
296298
'parent' => 'prpl-activities',
297-
'title' => $activity->data_id . ' - ' . $activity->category,
299+
'title' => \esc_html( $activity->data_id . ' - ' . $activity->category ),
298300
]
299301
);
300302
}

0 commit comments

Comments
 (0)