Published : August 14, 2026

CVE-2026-16146: Invisible Anti-Spam & CAPTCHA <= 5.1 Authenticated (Editor+) SQL Injection via Pattern JSON Keys/Values PoC, Patch Analysis & Rule

Severity Medium (CVSS 4.9)
CWE 89
Vulnerable Version 5.1
Patched Version 5.1.1
Disclosed August 13, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-16146: This is an authenticated SQL injection vulnerability in the Invisible Anti-Spam & CAPTCHA plugin for WordPress, specifically in how the plugin handles Pattern JSON keys and values. The vulnerability affects versions up to and including 5.1 and allows attackers with Editor-level access (capability ‘edit_pages’) to perform generic SQL injection by manipulating the pattern configuration. The flaw is a combination of insufficient escaping on user-supplied parameters and a lack of prepared statements in the SQL query construction, which allows an attacker to append additional SQL statements.

Atomic Edge research identifies the root cause in the SQL query building logic within two key files: includes/class-option.php and includes/class-message-page.php. The vulnerable functions, get_rows() and get_messages(), construct SQL conditions by directly interpolating the keys and values from a JSON pattern into a query using double-quoted strings. Specifically, in class-option.php around lines 312-333, the code builds conditions like (rgd.rgd_attribute LIKE ‘{$param_path}’) and (rgd.rgd_value = ‘{$value}’) without calling esc_sql(). The values for $param_path and $value originate from the ‘key’ POST parameter, which is processed in the save_pattern_callback() function in class-message-page.php. While the original code applied sanitize_text_field() and stripslashes(), this did not protect against SQL injection because it fails to neutralize single quote characters that are essential for breaking out of the SQL string literal. The get_patterns() method in class-analysis.php also exhibits this vulnerable pattern, constructing similar SQL conditions without proper escaping or using WordPress’s $wpdb->prepare() method.

To exploit this vulnerability, an authenticated attacker with at least Editor privileges would need to access the vulnerable AJAX endpoints. The attack flow involves first setting a malicious pattern and then triggering a search or message listing. The attacker would send a POST request to admin-ajax.php with the action ‘save_pattern’ to store a crafted value in the ‘key’ parameter. For example, a payload like test’) UNION SELECT user_login, user_pass FROM wp_users– – would be stored as a pattern key. After storing the malicious pattern, the attacker triggers another AJAX call, such as analyzing a message or listing messages, which causes the plugin to invoke the vulnerable get_messages() or get_rows() function. Since the stored pattern is used directly in the SQL query, the single quote in the payload breaks out of the LIKE clause, and the appended UNION SELECT statement executes, potentially extracting usernames and password hashes.

The patch addresses this vulnerability through a multi-layered approach. The primary fix is applying esc_sql() to the $param_path and $value variables in the SQL condition construction within both get_rows() and get_messages() functions in class-option.php and class-message-page.php. This ensures that single quotes and other special characters are properly escaped before being used in SQL queries. Additionally, the patch introduces an authorisation gate (checking current_user_can(‘manage_options’)) in multiple AJAX callbacks, including save_pattern_callback(), save_list_parameter_callback(), and get_patterns(). This changes the capability requirement from ‘edit_pages’ (which is available to Editors) to ‘manage_options’ (which is only available to Administrators). This change means that even if the SQL injection flaw were triggered, the ability to reach the vulnerable code is restricted to significantly fewer users, reducing the attack surface and neutralizing the threat for most lower-privileged authenticated roles.

Successful exploitation of this SQL injection vulnerability could allow an authenticated attacker with Editor-level access to extract any data from the WordPress database. This includes sensitive information such as user credentials (usernames, password hashes, and session tokens), personally identifiable information from custom tables, and potentially confidential business data. Further SQL injection payloads could be used to create new administrative users, modify existing content, or perform other destructive actions that compromise the integrity and confidentiality of the entire WordPress installation. The confidentiality impact is rated as High, while the integrity and availability impacts are rated as Low, making this a serious data breach risk for WordPress sites.

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 );

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.