diff --git a/assets/js/suggested-task.js b/assets/js/suggested-task.js
index 42bd863074..6b27d085b9 100644
--- a/assets/js/suggested-task.js
+++ b/assets/js/suggested-task.js
@@ -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 );
},
diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php
index d88ff5dc7b..751bb4dd8f 100644
--- a/classes/class-suggested-tasks.php
+++ b/classes/class-suggested-tasks.php
@@ -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 );
}
@@ -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.
*
diff --git a/classes/update/class-update-191.php b/classes/update/class-update-191.php
new file mode 100644
index 0000000000..1924ee45cf
--- /dev/null
+++ b/classes/update/class-update-191.php
@@ -0,0 +1,63 @@
+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 ] );
+ }
+ }
+}
diff --git a/classes/utils/class-debug-tools.php b/classes/utils/class-debug-tools.php
index d6f1d5e302..dc50b7761b 100644
--- a/classes/utils/class-debug-tools.php
+++ b/classes/utils/class-debug-tools.php
@@ -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 . ' ×',
+ 'title' => \esc_html( $title ) . ' ×',
]
);
}
@@ -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 ),
]
);
}
diff --git a/composer.lock b/composer.lock
index 595196b7e0..5447c8c5fa 100644
--- a/composer.lock
+++ b/composer.lock
@@ -245,16 +245,16 @@
},
{
"name": "composer/ca-bundle",
- "version": "1.5.10",
+ "version": "1.5.12",
"source": {
"type": "git",
"url": "https://github.com/composer/ca-bundle.git",
- "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63"
+ "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63",
- "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63",
+ "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78",
+ "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78",
"shasum": ""
},
"require": {
@@ -301,7 +301,7 @@
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues",
- "source": "https://github.com/composer/ca-bundle/tree/1.5.10"
+ "source": "https://github.com/composer/ca-bundle/tree/1.5.12"
},
"funding": [
{
@@ -313,20 +313,20 @@
"type": "github"
}
],
- "time": "2025-12-08T15:06:51+00:00"
+ "time": "2026-05-19T11:26:22+00:00"
},
{
"name": "composer/class-map-generator",
- "version": "1.7.0",
+ "version": "1.7.3",
"source": {
"type": "git",
"url": "https://github.com/composer/class-map-generator.git",
- "reference": "2373419b7709815ed323ebf18c3c72d03ff4a8a6"
+ "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/class-map-generator/zipball/2373419b7709815ed323ebf18c3c72d03ff4a8a6",
- "reference": "2373419b7709815ed323ebf18c3c72d03ff4a8a6",
+ "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f",
+ "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f",
"shasum": ""
},
"require": {
@@ -370,7 +370,7 @@
],
"support": {
"issues": "https://github.com/composer/class-map-generator/issues",
- "source": "https://github.com/composer/class-map-generator/tree/1.7.0"
+ "source": "https://github.com/composer/class-map-generator/tree/1.7.3"
},
"funding": [
{
@@ -382,20 +382,20 @@
"type": "github"
}
],
- "time": "2025-11-19T10:41:15+00:00"
+ "time": "2026-05-05T09:17:07+00:00"
},
{
"name": "composer/composer",
- "version": "2.9.2",
+ "version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/composer/composer.git",
- "reference": "8d5358f147c63a3a681b002076deff8c90e0b19d"
+ "reference": "c13824d95608b15913a7c0def0a3dea4474b71fc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/composer/zipball/8d5358f147c63a3a681b002076deff8c90e0b19d",
- "reference": "8d5358f147c63a3a681b002076deff8c90e0b19d",
+ "url": "https://api.github.com/repos/composer/composer/zipball/c13824d95608b15913a7c0def0a3dea4474b71fc",
+ "reference": "c13824d95608b15913a7c0def0a3dea4474b71fc",
"shasum": ""
},
"require": {
@@ -448,7 +448,7 @@
]
},
"branch-alias": {
- "dev-main": "2.9-dev"
+ "dev-main": "2.10-dev"
}
},
"autoload": {
@@ -483,7 +483,7 @@
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/composer/issues",
"security": "https://github.com/composer/composer/security/policy",
- "source": "https://github.com/composer/composer/tree/2.9.2"
+ "source": "https://github.com/composer/composer/tree/2.10.0"
},
"funding": [
{
@@ -495,7 +495,7 @@
"type": "github"
}
],
- "time": "2025-11-19T20:57:25+00:00"
+ "time": "2026-05-28T09:22:08+00:00"
},
{
"name": "composer/metadata-minifier",
@@ -724,24 +724,24 @@
},
{
"name": "composer/spdx-licenses",
- "version": "1.5.9",
+ "version": "1.6.0",
"source": {
"type": "git",
"url": "https://github.com/composer/spdx-licenses.git",
- "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f"
+ "reference": "5ecd0cb4177696f9fd48f1605dda81db3dee7889"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/edf364cefe8c43501e21e88110aac10b284c3c9f",
- "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f",
+ "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/5ecd0cb4177696f9fd48f1605dda81db3dee7889",
+ "reference": "5ecd0cb4177696f9fd48f1605dda81db3dee7889",
"shasum": ""
},
"require": {
- "php": "^5.3.2 || ^7.0 || ^8.0"
+ "php": "^7.2 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
- "symfony/phpunit-bridge": "^3 || ^7"
+ "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3 || ^8.0"
},
"type": "library",
"extra": {
@@ -784,7 +784,7 @@
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/spdx-licenses/issues",
- "source": "https://github.com/composer/spdx-licenses/tree/1.5.9"
+ "source": "https://github.com/composer/spdx-licenses/tree/1.6.0"
},
"funding": [
{
@@ -794,13 +794,9 @@
{
"url": "https://github.com/composer",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
}
],
- "time": "2025-05-12T21:07:07+00:00"
+ "time": "2026-04-08T20:18:39+00:00"
},
{
"name": "composer/xdebug-handler",
@@ -1512,16 +1508,16 @@
},
{
"name": "justinrainbow/json-schema",
- "version": "6.6.3",
+ "version": "6.8.2",
"source": {
"type": "git",
"url": "https://github.com/jsonrainbow/json-schema.git",
- "reference": "134e98916fa2f663afa623970af345cd788e8967"
+ "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/134e98916fa2f663afa623970af345cd788e8967",
- "reference": "134e98916fa2f663afa623970af345cd788e8967",
+ "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76",
+ "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76",
"shasum": ""
},
"require": {
@@ -1531,7 +1527,7 @@
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.3.0",
- "json-schema/json-schema-test-suite": "^23.2",
+ "json-schema/json-schema-test-suite": "dev-main",
"marc-mabe/php-enum-phpstan": "^2.0",
"phpspec/prophecy": "^1.19",
"phpstan/phpstan": "^1.12",
@@ -1581,9 +1577,9 @@
],
"support": {
"issues": "https://github.com/jsonrainbow/json-schema/issues",
- "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.3"
+ "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2"
},
- "time": "2025-12-02T10:21:33+00:00"
+ "time": "2026-05-05T05:39:01+00:00"
},
{
"name": "marc-mabe/php-enum",
@@ -1897,16 +1893,16 @@
},
{
"name": "nikic/php-parser",
- "version": "v5.6.2",
+ "version": "v5.7.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "3a454ca033b9e06b63282ce19562e892747449bb"
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb",
- "reference": "3a454ca033b9e06b63282ce19562e892747449bb",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
@@ -1949,9 +1945,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
},
- "time": "2025-10-21T19:32:17+00:00"
+ "time": "2025-12-06T11:56:16+00:00"
},
{
"name": "phar-io/manifest",
@@ -2722,16 +2718,16 @@
},
{
"name": "phpstan/phpdoc-parser",
- "version": "2.3.0",
+ "version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
- "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495"
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495",
- "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
"shasum": ""
},
"require": {
@@ -2763,9 +2759,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
- "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0"
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
},
- "time": "2025-08-30T15:50:23+00:00"
+ "time": "2026-01-25T14:56:51+00:00"
},
{
"name": "phpstan/phpstan",
@@ -3141,16 +3137,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "9.6.30",
+ "version": "9.6.34",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4"
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b69489b312503bf8fa6d75a76916919d7d2fa6d4",
- "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a",
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a",
"shasum": ""
},
"require": {
@@ -3172,7 +3168,7 @@
"phpunit/php-timer": "^5.0.3",
"sebastian/cli-parser": "^1.0.2",
"sebastian/code-unit": "^1.0.8",
- "sebastian/comparator": "^4.0.9",
+ "sebastian/comparator": "^4.0.10",
"sebastian/diff": "^4.0.6",
"sebastian/environment": "^5.1.5",
"sebastian/exporter": "^4.0.8",
@@ -3224,7 +3220,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.30"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34"
},
"funding": [
{
@@ -3248,7 +3244,7 @@
"type": "tidelift"
}
],
- "time": "2025-12-01T07:35:08+00:00"
+ "time": "2026-01-27T05:45:00+00:00"
},
{
"name": "psr/container",
@@ -4039,6 +4035,7 @@
"type": "github"
}
],
+ "abandoned": true,
"time": "2020-10-26T13:08:54+00:00"
},
{
@@ -4094,20 +4091,21 @@
"type": "github"
}
],
+ "abandoned": true,
"time": "2020-09-28T05:30:19+00:00"
},
{
"name": "sebastian/comparator",
- "version": "4.0.9",
+ "version": "4.0.10",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5"
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5",
- "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
"shasum": ""
},
"require": {
@@ -4160,7 +4158,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
- "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
},
"funding": [
{
@@ -4180,7 +4178,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-10T06:51:50+00:00"
+ "time": "2026-01-24T09:22:56+00:00"
},
{
"name": "sebastian/complexity",
@@ -5315,16 +5313,16 @@
},
{
"name": "symfony/console",
- "version": "v7.4.0",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8"
+ "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8",
- "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8",
+ "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217",
+ "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217",
"shasum": ""
},
"require": {
@@ -5389,7 +5387,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.4.0"
+ "source": "https://github.com/symfony/console/tree/v7.4.13"
},
"funding": [
{
@@ -5409,20 +5407,20 @@
"type": "tidelift"
}
],
- "time": "2025-11-27T13:27:24+00:00"
+ "time": "2026-05-24T08:56:14+00:00"
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@@ -5435,7 +5433,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -5460,7 +5458,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -5471,12 +5469,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/event-dispatcher",
@@ -5641,16 +5643,16 @@
},
{
"name": "symfony/filesystem",
- "version": "v7.4.0",
+ "version": "v7.4.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "d551b38811096d0be9c4691d406991b47c0c630a"
+ "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a",
- "reference": "d551b38811096d0be9c4691d406991b47c0c630a",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50",
+ "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50",
"shasum": ""
},
"require": {
@@ -5687,7 +5689,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v7.4.0"
+ "source": "https://github.com/symfony/filesystem/tree/v7.4.11"
},
"funding": [
{
@@ -5707,20 +5709,20 @@
"type": "tidelift"
}
],
- "time": "2025-11-27T13:27:24+00:00"
+ "time": "2026-05-11T16:38:44+00:00"
},
{
"name": "symfony/finder",
- "version": "v7.4.0",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "340b9ed7320570f319028a2cbec46d40535e94bd"
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd",
- "reference": "340b9ed7320570f319028a2cbec46d40535e94bd",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149",
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149",
"shasum": ""
},
"require": {
@@ -5755,7 +5757,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v7.4.0"
+ "source": "https://github.com/symfony/finder/tree/v7.4.8"
},
"funding": [
{
@@ -5775,7 +5777,7 @@
"type": "tidelift"
}
],
- "time": "2025-11-05T05:42:40+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/options-resolver",
@@ -5850,16 +5852,16 @@
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
@@ -5909,7 +5911,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -5929,20 +5931,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ "reference": "e9247d281d694a5120554d9afaf54e070e88a603"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603",
+ "reference": "e9247d281d694a5120554d9afaf54e070e88a603",
"shasum": ""
},
"require": {
@@ -5991,7 +5993,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1"
},
"funding": [
{
@@ -6011,20 +6013,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-27T09:58:17+00:00"
+ "time": "2026-05-26T05:58:03+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.33.0",
+ "version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"shasum": ""
},
"require": {
@@ -6076,7 +6078,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
},
"funding": [
{
@@ -6096,20 +6098,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-25T13:48:31+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92",
+ "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92",
"shasum": ""
},
"require": {
@@ -6161,7 +6163,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1"
},
"funding": [
{
@@ -6181,11 +6183,11 @@
"type": "tidelift"
}
],
- "time": "2024-12-23T08:48:59+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/polyfill-php73",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
@@ -6241,7 +6243,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0"
},
"funding": [
{
@@ -6265,16 +6267,16 @@
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": ""
},
"require": {
@@ -6325,7 +6327,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
},
"funding": [
{
@@ -6345,20 +6347,20 @@
"type": "tidelift"
}
],
- "time": "2025-01-02T08:10:11+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php81",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
+ "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+ "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
+ "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
"shasum": ""
},
"require": {
@@ -6405,7 +6407,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1"
},
"funding": [
{
@@ -6425,20 +6427,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-26T12:45:58+00:00"
},
{
"name": "symfony/polyfill-php84",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php84.git",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
"shasum": ""
},
"require": {
@@ -6485,7 +6487,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
},
"funding": [
{
@@ -6505,20 +6507,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-24T13:30:11+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/process",
- "version": "v7.4.0",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8"
+ "reference": "f5804be144caceb570f6747519999636b664f24c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8",
- "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8",
+ "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c",
+ "reference": "f5804be144caceb570f6747519999636b664f24c",
"shasum": ""
},
"require": {
@@ -6550,7 +6552,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v7.4.0"
+ "source": "https://github.com/symfony/process/tree/v7.4.13"
},
"funding": [
{
@@ -6570,20 +6572,20 @@
"type": "tidelift"
}
],
- "time": "2025-10-16T11:21:06+00:00"
+ "time": "2026-05-23T16:05:06+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
"shasum": ""
},
"require": {
@@ -6601,7 +6603,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -6637,7 +6639,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -6657,7 +6659,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2026-03-28T09:44:51+00:00"
},
{
"name": "symfony/stopwatch",
@@ -6727,16 +6729,16 @@
},
{
"name": "symfony/string",
- "version": "v7.4.0",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003"
+ "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003",
- "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003",
+ "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde",
+ "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde",
"shasum": ""
},
"require": {
@@ -6794,7 +6796,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v7.4.0"
+ "source": "https://github.com/symfony/string/tree/v7.4.13"
},
"funding": [
{
@@ -6814,7 +6816,7 @@
"type": "tidelift"
}
],
- "time": "2025-11-27T13:27:24+00:00"
+ "time": "2026-05-23T15:23:29+00:00"
},
{
"name": "szepeviktor/phpstan-wordpress",
diff --git a/progress-planner.php b/progress-planner.php
index 5fa2c26a6a..ee662ae7b4 100644
--- a/progress-planner.php
+++ b/progress-planner.php
@@ -9,7 +9,7 @@
* Description: A plugin to help you fight procrastination and get things done.
* Requires at least: 6.7
* Requires PHP: 7.4
- * Version: 1.9.0
+ * Version: 1.9.1
* Author: Team Emilia Projects
* Author URI: https://prpl.fyi/about
* License: GPL-3.0+
diff --git a/readme.txt b/readme.txt
index cfdcaadff6..c7701aaa4d 100644
--- a/readme.txt
+++ b/readme.txt
@@ -4,7 +4,7 @@ Tags: planning, maintenance, writing, blogging
Requires at least: 6.7
Tested up to: 6.9
Requires PHP: 7.4
-Stable tag: 1.9.0
+Stable tag: 1.9.1
License: GPL3+
License URI: https://www.gnu.org/licenses/gpl-3.0.en.html
@@ -83,6 +83,11 @@ https://youtu.be/e1bmxZYyXFY
== Changelog ==
+= 1.9.1 =
+
+- Security fix: Authenticated (Editor and above) Stored Cross-Site Scripting (XSS) via recommendation titles. Titles are now sanitized when saved, and existing recommendations are cleaned up via an update script.
+- Thanks to [hongdo](https://patchstack.com/database/researchers/b19114df-00a1-4c42-b2f1-627b22001d57) for responsibly disclosing this issue via the Patchstack Bug Bounty Program.
+
= 1.9.0 =
In this release we've added an integration with the **All In One Seo** plugin so you’ll now see personalized suggestions based on your current SEO configuration.
diff --git a/tests/phpunit/test-class-rest-recommendations-xss.php b/tests/phpunit/test-class-rest-recommendations-xss.php
new file mode 100644
index 0000000000..0f93afa705
--- /dev/null
+++ b/tests/phpunit/test-class-rest-recommendations-xss.php
@@ -0,0 +1,169 @@
+`) which was later rendered
+ * unescaped in the admin dashboard.
+ *
+ * @package Progress_Planner\Tests
+ */
+
+namespace Progress_Planner\Tests;
+
+/**
+ * Class Rest_Recommendations_Xss_Test
+ */
+class Rest_Recommendations_Xss_Test extends \WP_UnitTestCase {
+
+ /**
+ * Set up the REST server before each test.
+ *
+ * @return void
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ global $wp_rest_server;
+ $wp_rest_server = new \WP_REST_Server();
+ \do_action( 'rest_api_init' );
+ }
+
+ /**
+ * Tear down the REST server after each test.
+ *
+ * @return void
+ */
+ public function tearDown(): void {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+ parent::tearDown();
+ }
+
+ /**
+ * Submit a recommendation via the REST API and return the raw response.
+ *
+ * @param string $title The title to submit.
+ * @return \WP_REST_Response The REST response.
+ */
+ private function submit_recommendation_via_rest( $title ) {
+ $request = new \WP_REST_Request( 'POST', '/wp/v2/prpl_recommendations' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ (string) \wp_json_encode(
+ [
+ 'title' => $title,
+ 'status' => 'publish',
+ ]
+ )
+ );
+
+ return \rest_get_server()->dispatch( $request );
+ }
+
+ /**
+ * Create a recommendation via the REST API and return the created WP_Post.
+ *
+ * @param string $title The title to submit.
+ * @return \WP_Post The created post.
+ */
+ private function create_recommendation_via_rest( $title ) {
+ $response = $this->submit_recommendation_via_rest( $title );
+ $this->assertSame( 201, $response->get_status(), 'Recommendation should be created.' );
+
+ $data = $response->get_data();
+ return \get_post( $data['id'] );
+ }
+
+ /**
+ * An Editor submitting a title that is purely an HTML payload must not result
+ * in a stored post that contains the markup.
+ *
+ * Stripping the tags leaves an empty title, so WordPress rejects the request
+ * outright (the malicious post is never created) - an acceptable, even
+ * preferable, outcome. We assert that either nothing was stored, or if it was,
+ * the markup is gone.
+ *
+ * @return void
+ */
+ public function test_editor_xss_title_is_stripped() {
+ $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] );
+ \wp_set_current_user( $editor_id );
+
+ $response = $this->submit_recommendation_via_rest( '
' );
+
+ if ( 201 === $response->get_status() ) {
+ $post = \get_post( $response->get_data()['id'] );
+ $this->assertStringNotContainsString( '
post_title );
+ $this->assertStringNotContainsString( 'onerror', $post->post_title );
+ $this->assertStringNotContainsString( '<', $post->post_title );
+ } else {
+ // An empty title (after stripping) is rejected; no post is created.
+ $this->assertSame( 400, $response->get_status() );
+ }
+ }
+
+ /**
+ * A Hello' );
+
+ $this->assertStringNotContainsString( 'post_title );
+ // The plain-text remainder is preserved.
+ $this->assertStringContainsString( 'Hello', $post->post_title );
+ }
+
+ /**
+ * Even an Administrator (who has the `unfiltered_html` capability) should
+ * have HTML stripped from recommendation titles, since they are rendered as
+ * plain text in JS templates.
+ *
+ * @return void
+ */
+ public function test_admin_with_unfiltered_html_title_is_stripped() {
+ $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ \wp_set_current_user( $admin_id );
+
+ $post = $this->create_recommendation_via_rest( '
Title' );
+
+ $this->assertStringNotContainsString( '
post_title );
+ $this->assertStringNotContainsString( 'onerror', $post->post_title );
+ $this->assertStringContainsString( 'Title', $post->post_title );
+ }
+
+ /**
+ * Legitimate plain-text titles (including ampersands) must be preserved.
+ *
+ * @return void
+ */
+ public function test_plain_text_title_is_preserved() {
+ $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] );
+
+ // This test isolates our XSS sanitization: a legitimate plain-text title
+ // must survive `wp_strip_all_tags()` + `sanitize_text_field()` unchanged.
+ // Core's kses would additionally encode the ampersand, but only for users
+ // without `unfiltered_html` — which on multisite excludes editors. Grant
+ // the capability *before* switching the current user, because `kses_init()`
+ // (hooked on `set_current_user`) decides whether to attach the kses filters
+ // at switch time; granting it afterwards would be too late.
+ if ( \is_multisite() ) {
+ \grant_super_admin( $editor_id );
+ } else {
+ ( new \WP_User( $editor_id ) )->add_cap( 'unfiltered_html' );
+ }
+
+ \wp_set_current_user( $editor_id );
+
+ $post = $this->create_recommendation_via_rest( 'Buy milk & eggs' );
+
+ $this->assertSame( 'Buy milk & eggs', $post->post_title );
+ }
+}
diff --git a/tests/phpunit/test-class-security.php b/tests/phpunit/test-class-security.php
index c9073f9d7d..57f2f441ff 100644
--- a/tests/phpunit/test-class-security.php
+++ b/tests/phpunit/test-class-security.php
@@ -189,6 +189,15 @@ public function print_popover_form_contents() {}
public function evaluate() {
return false;
}
+
+ /**
+ * Check if the task condition is satisfied.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ return true;
+ }
};
// Set initial value.
@@ -273,6 +282,15 @@ public function print_popover_form_contents() {}
public function evaluate() {
return false;
}
+
+ /**
+ * Check if the task condition is satisfied.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ return true;
+ }
};
// Test without nonce.
@@ -334,6 +352,15 @@ public function print_popover_form_contents() {}
public function evaluate() {
return false;
}
+
+ /**
+ * Check if the task condition is satisfied.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ return true;
+ }
};
$_POST['nonce'] = \wp_create_nonce( 'progress_planner' );
@@ -394,6 +421,15 @@ public function print_popover_form_contents() {}
public function evaluate() {
return false;
}
+
+ /**
+ * Check if the task condition is satisfied.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ return true;
+ }
};
// Set up a nested option.
@@ -470,6 +506,15 @@ public function print_popover_form_contents() {}
public function evaluate() {
return false;
}
+
+ /**
+ * Check if the task condition is satisfied.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ return true;
+ }
};
// Test 1: Try to update a non-whitelisted option (should FAIL with fix).
@@ -548,6 +593,15 @@ public function print_popover_form_contents() {}
public function evaluate() {
return false;
}
+
+ /**
+ * Check if the task condition is satisfied.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ return true;
+ }
};
// Test updating a whitelisted option (should SUCCEED).
@@ -621,6 +675,15 @@ public function print_popover_form_contents() {}
public function evaluate() {
return false;
}
+
+ /**
+ * Check if the task condition is satisfied.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ return true;
+ }
};
// Test updating the custom whitelisted option (should SUCCEED).
@@ -687,6 +750,15 @@ public function print_popover_form_contents() {}
public function evaluate() {
return false;
}
+
+ /**
+ * Check if the task condition is satisfied.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ return true;
+ }
};
$critical_options = [
diff --git a/tests/phpunit/test-class-upgrade-migration-191.php b/tests/phpunit/test-class-upgrade-migration-191.php
new file mode 100644
index 0000000000..bb912968ec
--- /dev/null
+++ b/tests/phpunit/test-class-upgrade-migration-191.php
@@ -0,0 +1,129 @@
+post->create(
+ [
+ 'post_type' => 'prpl_recommendations',
+ 'post_title' => 'placeholder',
+ 'post_content' => '',
+ 'post_excerpt' => '',
+ 'post_status' => 'publish',
+ ]
+ );
+
+ $wpdb->update( $wpdb->posts, [ 'post_title' => $raw_title ], [ 'ID' => $post_id ] ); // phpcs:ignore WordPress.DB
+ \clean_post_cache( $post_id );
+
+ return $post_id;
+ }
+
+ /**
+ * Test that HTML tags are stripped from existing recommendation titles.
+ *
+ * @return void
+ */
+ public function test_migration_strips_tags_from_titles() {
+ \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations();
+
+ $task_id = $this->create_recommendation_with_raw_title( '
Hello' );
+
+ // Sanity check: the malicious markup is stored before the migration.
+ $this->assertStringContainsString( '
post_title );
+
+ ( new \Progress_Planner\Update\Update_191() )->run();
+
+ $title = \get_post( $task_id )->post_title;
+ $this->assertStringNotContainsString( '
assertStringNotContainsString( 'onerror', $title );
+ $this->assertStringContainsString( 'Hello', $title );
+ }
+
+ /**
+ * Test that a recommendation whose title is *pure* markup is deleted, even
+ * when post_content and post_excerpt are empty.
+ *
+ * Stripping leaves an empty title. The plugin never stores title-less
+ * recommendations, so the row is junk and is removed. (Updating it in place
+ * would hit WordPress's empty-content guard and leave the markup behind.)
+ *
+ * @return void
+ */
+ public function test_migration_deletes_pure_markup_title_with_empty_content() {
+ \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations();
+
+ $task_id = $this->create_recommendation_with_raw_title( '
' );
+
+ ( new \Progress_Planner\Update\Update_191() )->run();
+
+ $this->assertNull( \get_post( $task_id ), 'Pure-markup recommendation should be deleted.' );
+ }
+
+ /**
+ * Test that a Title' );
+
+ ( new \Progress_Planner\Update\Update_191() )->run();
+
+ $title = \get_post( $task_id )->post_title;
+ $this->assertStringNotContainsString( '