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 ); + $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( 'assertStringContainsString( 'Title', $title ); + } + + /** + * Test that a legitimate plain-text title is left unchanged. + * + * @return void + */ + public function test_migration_preserves_plain_text_titles() { + \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations(); + + $task_id = $this->create_recommendation_with_raw_title( 'Buy milk & eggs' ); + + $modified_before = \get_post( $task_id )->post_modified; + + ( new \Progress_Planner\Update\Update_191() )->run(); + + $post = \get_post( $task_id ); + $this->assertSame( 'Buy milk & eggs', $post->post_title ); + // A title that needs no change must not be re-saved. + $this->assertSame( $modified_before, $post->post_modified, 'Clean title should not be modified.' ); + } +} diff --git a/views/js-templates/suggested-task.html b/views/js-templates/suggested-task.html index f4723088c2..ec40d3b55e 100644 --- a/views/js-templates/suggested-task.html +++ b/views/js-templates/suggested-task.html @@ -4,6 +4,7 @@ <# if ( 'user' === data.post.prpl_provider.slug ) { #> <# } else { #> @@ -15,6 +16,7 @@

+ <# /* title.rendered is already sanitized, so it is safe to print raw. */ #> contenteditable="plaintext-only" role="textbox" aria-label="Edit task title" aria-multiline="false" onkeydown="prplSuggestedTask.preventEnterKey( event ); prplSuggestedTask.updateTaskTitle( this );" data-post-id="{{ data.post.id }}"<# } #>>{{{ data.post.title.rendered }}}