Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- 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 );