From 8094c88aed01f2e299a44c1ad3840e7d69aa790c Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:08:07 +0100 Subject: [PATCH 01/10] Fixed: prevent data corruption when saving options during an AJAX request. --- src/Ajax.php | 76 ++++++++++++++++++++++++++----------------------- src/Helpers.php | 11 ------- 2 files changed, 41 insertions(+), 46 deletions(-) diff --git a/src/Ajax.php b/src/Ajax.php index e72aea4..be428e6 100644 --- a/src/Ajax.php +++ b/src/Ajax.php @@ -76,7 +76,7 @@ public function fetch_messages() { public function quit_wizard() { $request_data = $this->clean( $_REQUEST ); - if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $request_data[ '_nonce' ], 'plausible_analytics_quit_wizard' ) < 1 ) { + if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $request_data['_nonce'], 'plausible_analytics_quit_wizard' ) < 1 ) { Messages::set_error( __( 'Not allowed', 'plausible-analytics' ) ); wp_send_json_error( null, 403 ); @@ -84,7 +84,7 @@ public function quit_wizard() { update_option( 'plausible_analytics_wizard_done', true ); - $this->maybe_handle_redirect( $request_data[ 'redirect' ] ); + $this->maybe_handle_redirect( $request_data['redirect'] ); wp_send_json_success(); } @@ -93,12 +93,12 @@ public function quit_wizard() { * Clean variables using `sanitize_text_field`. * Arrays are cleaned recursively. Non-scalar values are ignored. * - * @since 1.3.0 - * @access public - * * @param string|array $var Sanitize the variable. * * @return string|array + * @since 1.3.0 + * @access public + * */ private function clean( $var ) { // If the variable is an array, recursively apply the function to each element of the array. @@ -111,8 +111,8 @@ private function clean( $var ) { // Parse the variable using the wp_parse_url function. $parsed = wp_parse_url( $var ); // If the variable has a scheme (e.g. http:// or https://), sanitize the variable using the esc_url_raw function. - if ( isset( $parsed[ 'scheme' ] ) ) { - return esc_url_raw( wp_unslash( $var ), [ $parsed[ 'scheme' ] ] ); + if ( isset( $parsed['scheme'] ) ) { + return esc_url_raw( wp_unslash( $var ), [ $parsed['scheme'] ] ); } // If the variable does not have a scheme, sanitize the variable using the sanitize_text_field function. @@ -157,7 +157,7 @@ private function maybe_handle_redirect( $direction ) { public function show_wizard() { $request_data = $this->clean( $_REQUEST ); - if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $request_data[ '_nonce' ], 'plausible_analytics_show_wizard' ) < 1 ) { + if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $request_data['_nonce'], 'plausible_analytics_show_wizard' ) < 1 ) { Messages::set_error( __( 'Not allowed.', 'plausible-analytics' ) ); wp_send_json_error( null, 403 ); @@ -165,7 +165,7 @@ public function show_wizard() { delete_option( 'plausible_analytics_wizard_done' ); - $this->maybe_handle_redirect( $request_data[ 'redirect' ] ); + $this->maybe_handle_redirect( $request_data['redirect'] ); wp_send_json_success(); } @@ -173,38 +173,38 @@ public function show_wizard() { /** * Save Admin Settings * - * @since 1.0.0 * @return void + * @since 1.0.0 */ public function toggle_option() { // Sanitize all the post data before using. $post_data = $this->clean( $_POST ); $settings = Helpers::get_settings(); - if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $post_data[ '_nonce' ], 'plausible_analytics_toggle_option' ) < 1 ) { + if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $post_data['_nonce'], 'plausible_analytics_toggle_option' ) < 1 ) { wp_send_json_error( __( 'Not allowed.', 'plausible-analytics' ), 403 ); } - if ( $post_data[ 'is_list' ] ) { + if ( $post_data['is_list'] ) { /** * Toggle lists. */ - if ( $post_data[ 'toggle_status' ] === 'on' ) { + if ( $post_data['toggle_status'] === 'on' ) { // If toggle is on, store the value under a new key. - if ( ! in_array( $post_data[ 'option_value' ], $settings[ $post_data[ 'option_name' ] ] ) ) { - $settings[ $post_data[ 'option_name' ] ][] = $post_data[ 'option_value' ]; + if ( ! in_array( $post_data['option_value'], $settings[ $post_data['option_name'] ] ) ) { + $settings[ $post_data['option_name'] ][] = $post_data['option_value']; } } else { // If toggle is off, find the key by its value and unset it. - if ( ( $key = array_search( $post_data[ 'option_value' ], $settings[ $post_data[ 'option_name' ] ] ) ) !== false ) { - unset( $settings[ $post_data[ 'option_name' ] ][ $key ] ); + if ( ( $key = array_search( $post_data['option_value'], $settings[ $post_data['option_name'] ] ) ) !== false ) { + unset( $settings[ $post_data['option_name'] ][ $key ] ); } } } else { /** * Single toggles. */ - $settings[ $post_data[ 'option_name' ] ] = $post_data[ 'toggle_status' ]; + $settings[ $post_data['option_name'] ] = $post_data['toggle_status']; } // Update all the options to plausible settings. @@ -213,22 +213,22 @@ public function toggle_option() { /** * Allow devs to perform additional actions. */ - do_action( 'plausible_analytics_settings_saved', $settings, $post_data[ 'option_name' ], $post_data[ 'toggle_status' ] ); + do_action( 'plausible_analytics_settings_saved', $settings, $post_data['option_name'], $post_data['toggle_status'] ); - $option_label = $post_data[ 'option_label' ]; - $toggle_status = $post_data[ 'toggle_status' ] === 'on' ? __( 'enabled', 'plausible-analytics' ) : __( 'disabled', 'plausible-analytics' ); + $option_label = $post_data['option_label']; + $toggle_status = $post_data['toggle_status'] === 'on' ? __( 'enabled', 'plausible-analytics' ) : __( 'disabled', 'plausible-analytics' ); $message = apply_filters( 'plausible_analytics_toggle_option_success_message', sprintf( '%s %s.', $option_label, $toggle_status ), - $post_data[ 'option_name' ], - $post_data[ 'toggle_status' ] + $post_data['option_name'], + $post_data['toggle_status'] ); Messages::set_success( $message ); - $additional = $this->maybe_render_additional_message( $post_data[ 'option_name' ], $post_data[ 'toggle_status' ] ); + $additional = $this->maybe_render_additional_message( $post_data['option_name'], $post_data['toggle_status'] ); - Messages::set_additional( $additional, $post_data[ 'option_name' ] ); + Messages::set_additional( $additional, $post_data['option_name'] ); wp_send_json_success( null, 200 ); } @@ -271,17 +271,21 @@ public function save_options() { $post_data = $this->clean( $_POST ); $settings = Helpers::get_settings(); - if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $post_data[ '_nonce' ], 'plausible_analytics_toggle_option' ) < 1 ) { + if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $post_data['_nonce'], 'plausible_analytics_toggle_option' ) < 1 ) { Messages::set_error( __( 'Not allowed.', 'plausible-analytics' ) ); wp_send_json_error( null, 403 ); } - $options = json_decode( $post_data[ 'options' ] ); + $options = json_decode( stripslashes( $post_data['options'] ) ); if ( empty( $options ) ) { Messages::set_error( __( 'No options found to save.', 'plausible-analytics' ) ); + if ( defined( 'PLAUSIBLE_CI' ) ) { + return; + } + wp_send_json_error( null, 400 ); } @@ -298,14 +302,14 @@ function ( $option ) { ); if ( count( $input_array_elements ) > 0 ) { - $options = []; - $array_name = preg_replace( '/\[[0-9]+]/', '', $input_array_elements[ 0 ]->name ); - $options[ 0 ] = (object) []; - $options[ 0 ]->name = $array_name; + $options = []; + $array_name = preg_replace( '/\[[0-9]+]/', '', $input_array_elements[0]->name ); + $options[0] = (object) []; + $options[0]->name = $array_name; foreach ( $input_array_elements as $input_array_element ) { if ( $input_array_element->value ) { - $options[ 0 ]->value[] = $input_array_element->value; + $options[0]->value[] = $input_array_element->value; } } } @@ -328,8 +332,8 @@ function ( $option ) { } // Refresh Tracker ID if Domain Name has changed (e.g. after migration from staging to production) - if ($option->name === 'domain_name') { - delete_option('plausible_analytics_tracker_id'); + if ( $option->name === 'domain_name' ) { + delete_option( 'plausible_analytics_tracker_id' ); } } @@ -337,7 +341,9 @@ function ( $option ) { Messages::set_success( __( 'Settings saved.', 'plausible-analytics' ) ); - wp_send_json_success( null, 200 ); + if ( ! defined( 'PLAUSIBLE_CI' ) ) { + wp_send_json_success( null, 200 ); + } } /** diff --git a/src/Helpers.php b/src/Helpers.php index 5662ae4..edc647d 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -116,17 +116,6 @@ public static function get_settings() { $settings = get_option( 'plausible_analytics_settings', [] ); - /** - * If this is an AJAX request, make sure the latest settings are used. - */ - if ( isset( $_POST['action'] ) && $_POST['action'] === 'plausible_analytics_save_options' ) { - $options = json_decode( str_replace( '\\', '', $_POST['options'] ) ); - - foreach ( $options as $option ) { - $settings[ $option->name ] = $option->value; - } - } - return apply_filters( 'plausible_analytics_settings', wp_parse_args( $settings, $defaults ) ); } From 76c9f4ca8ff59285bd7e3bc27d09f2be38c97cd4 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:08:17 +0100 Subject: [PATCH 02/10] Added Tests for this edge case. --- src/Admin/Module.php | 5 ++ tests/TestCase.php | 4 ++ tests/integration/AjaxTest.php | 98 +++++++++++++++++++++++++++++++ tests/integration/HelpersTest.php | 2 +- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/integration/AjaxTest.php diff --git a/src/Admin/Module.php b/src/Admin/Module.php index 3bc6142..4087ca7 100644 --- a/src/Admin/Module.php +++ b/src/Admin/Module.php @@ -259,6 +259,11 @@ private function is_ssl() { * @since 1.3.0 */ private function test_proxy( $run = true ) { + // Always succeed if this is a CI environment. + if ( defined( 'PLAUSIBLE_CI' ) ) { + return true; + } + // Should we run the test? if ( ! apply_filters( 'plausible_analytics_module_run_test_proxy', $run ) ) { return false; // @codeCoverageIgnore diff --git a/tests/TestCase.php b/tests/TestCase.php index b4f9671..5a1bd4e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,6 +21,10 @@ public function __construct() { define( 'PLAUSIBLE_TESTS_ROOT', __DIR__ . '/' ); } + if ( ! defined( 'PLAUSIBLE_CI' ) ) { + define( 'PLAUSIBLE_CI', true ); + } + parent::__construct(); } diff --git a/tests/integration/AjaxTest.php b/tests/integration/AjaxTest.php new file mode 100644 index 0000000..ccdc495 --- /dev/null +++ b/tests/integration/AjaxTest.php @@ -0,0 +1,98 @@ +ajax = new Ajax(); + + // Ensure we are an admin for these tests. + $this->addUserCap( 'manage_options' ); + + // Mock nonce verification + add_filter( 'nonce_user_logged_out', '__return_true' ); + } + + /** + * Test save_options with normal JSON data. + */ + public function testSaveOptionsSuccess() { + $options = [ + [ 'name' => 'domain_name', 'value' => 'example.com' ], + [ 'name' => 'proxy_enabled', 'value' => 'on' ], + ]; + + $_POST['_nonce'] = wp_create_nonce( 'plausible_analytics_toggle_option' ); + $_POST['options'] = wp_json_encode( $options ); + + // We use catch because wp_send_json_success calls die() + try { + $this->ajax->save_options(); + } catch ( \Exception $e ) { + // Catching any unexpected exceptions + } + + $settings = Helpers::get_settings(); + $this->assertEquals( 'example.com', $settings['domain_name'] ); + $this->assertEquals( 'on', $settings['proxy_enabled'] ); + } + + /** + * Test save_options with escaped JSON data (simulating WordPress's $_POST behavior). + * This specifically tests the fix with stripslashes(). + */ + public function testSaveOptionsWithEscapedJson() { + $options = [ + [ 'name' => 'domain_name', 'value' => 'escaped.com' ], + ]; + + $json = wp_json_encode( $options ); + $escaped_json = addslashes( $json ); + + $_POST['_nonce'] = wp_create_nonce( 'plausible_analytics_toggle_option' ); + $_POST['options'] = $escaped_json; + + try { + $this->ajax->save_options(); + } catch ( \Exception $e ) { + } + + $settings = Helpers::get_settings(); + $this->assertEquals( 'escaped.com', $settings['domain_name'] ); + } + + /** + * Test save_options with invalid JSON. + */ + public function testSaveOptionsInvalidJson() { + $_POST['_nonce'] = wp_create_nonce( 'plausible_analytics_toggle_option' ); + $_POST['options'] = 'invalid-json'; + + // wp_send_json_error will be called, which we expect. + // In a real WP environment it would exit. + try { + $this->ajax->save_options(); + } catch ( \Exception $e ) { + } + + // Verify that settings were NOT updated to something weird. + $settings = Helpers::get_settings(); + $this->assertNotEquals( 'invalid-json', $settings['domain_name'] ); + } +} diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 37e1eb0..28930e6 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -103,7 +103,7 @@ public function testGetPostSettings() { $settings = Helpers::get_settings(); - $this->assertArrayHasKey( 'post_test', $settings ); + $this->assertArrayNotHasKey( 'post_test', $settings ); } /** From 32373b13bc9265bf9162883020cbb0960f787184 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:12:07 +0100 Subject: [PATCH 03/10] Properly tearDown after tests. And make sure the mock in TestableHelpers extends Client. --- tests/integration/AjaxTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/integration/AjaxTest.php b/tests/integration/AjaxTest.php index ccdc495..a8870c9 100644 --- a/tests/integration/AjaxTest.php +++ b/tests/integration/AjaxTest.php @@ -29,6 +29,19 @@ public function setUp(): void { add_filter( 'nonce_user_logged_out', '__return_true' ); } + /** + * Clean up after each test. + * + * @return void + */ + public function tearDown(): void { + parent::tearDown(); + + $_POST = []; + + remove_filter( 'nonce_user_logged_out', '__return_true' ); + } + /** * Test save_options with normal JSON data. */ From 781e9ad9afa6b176243b35db59e08faa098ed3ff Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:12:15 +0100 Subject: [PATCH 04/10] Make sure the mock in TestableHelpers extends Client. --- tests/TestableHelpers.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/TestableHelpers.php b/tests/TestableHelpers.php index b07c1a4..f2287e8 100644 --- a/tests/TestableHelpers.php +++ b/tests/TestableHelpers.php @@ -5,6 +5,7 @@ namespace Plausible\Analytics\Tests; +use Plausible\Analytics\WP\Client; use Plausible\Analytics\WP\Helpers; /** @@ -15,7 +16,7 @@ class TestableHelpers extends Helpers { * @return */ protected static function get_client() { - return new class { + return new class extends Client { public function get_tracker_id() { return 'pa-test-tracker-id'; } From 4b9a65527cfdfb475cceb05783abb0250b46f6bb Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:24:19 +0100 Subject: [PATCH 05/10] Fixed tests. --- src/Assets.php | 11 ++++----- tests/TestCase.php | 6 +++++ tests/integration/AdminBarTest.php | 2 ++ tests/integration/AssetsTest.php | 36 ++++++++++++++++++------------ tests/integration/HelpersTest.php | 2 ++ 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/Assets.php b/src/Assets.php index bf53286..aa9d7ba 100644 --- a/src/Assets.php +++ b/src/Assets.php @@ -21,7 +21,7 @@ public function __construct() { * @return void */ private function init() { - add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_main_script' ] ); + add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_main_script' ], 1 ); add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_cloaked_affiliate_links_assets' ], 11 ); add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_four_o_four_script' ], 11 ); add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_query_params_script' ], 11 ); @@ -41,6 +41,11 @@ public function maybe_enqueue_main_script() { $settings = Helpers::get_settings(); $user_role = Helpers::get_user_role(); + /** + * This is a dummy script that will allow us to attach inline scripts further down the line. + */ + wp_register_script( 'plausible-analytics', '' ); + /** * Bail if tracked_user_roles is empty (which means no roles should be tracked) or if the current role should not be tracked. */ @@ -48,10 +53,6 @@ public function maybe_enqueue_main_script() { return; // @codeCoverageIgnore } - /** - * This is a dummy script that will allow us to attach inline scripts further down the line. - */ - wp_register_script( 'plausible-analytics', '' ); wp_enqueue_script( 'plausible-analytics', '', diff --git a/tests/TestCase.php b/tests/TestCase.php index 5a1bd4e..ed4d4d3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -245,6 +245,12 @@ public function setQueryParams( $settings ) { return $settings; } + public function enableAdministratorTracking( $settings ) { + $settings['tracked_user_roles'][] = 'administrator'; + + return $settings; + } + /** * Checks an array for a (partial) match with $string. * diff --git a/tests/integration/AdminBarTest.php b/tests/integration/AdminBarTest.php index 1578052..82b0bb8 100644 --- a/tests/integration/AdminBarTest.php +++ b/tests/integration/AdminBarTest.php @@ -21,6 +21,8 @@ public function testAdminBarNode() { } wp_set_current_user( 1 ); + $user = wp_get_current_user(); + $user->add_role( 'administrator' ); $admin_bar = new WP_Admin_Bar(); $class->admin_bar_node( $admin_bar ); $this->assertNotEmpty( $admin_bar->get_node( 'plausible-analytics' ) ); diff --git a/tests/integration/AssetsTest.php b/tests/integration/AssetsTest.php index fc4c017..5efd16f 100644 --- a/tests/integration/AssetsTest.php +++ b/tests/integration/AssetsTest.php @@ -15,27 +15,35 @@ class AssetsTest extends TestCase { * @see Assets::maybe_enqueue_main_script() */ public function testEnqueueMainScript() { - $class = $this->getMockBuilder( Assets::class ) - ->disableOriginalConstructor() - ->onlyMethods( [ 'get_js_url' ] ) - ->getMock(); + try { + add_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); - $this->removeAction( 'wp_enqueue_scripts', 'maybe_enqueue' ); - $this->removeAction( 'wp_enqueue_scripts', 'maybe_enqueue', 11 ); + $class = $this->getMockBuilder( Assets::class ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'get_js_url' ] ) + ->getMock(); - $class->method( 'get_js_url' ) - ->willReturn( 'https://plausible.test/js/plausible.js' ); + $this->removeAction( 'wp_enqueue_scripts', 'maybe_enqueue' ); + $this->removeAction( 'wp_enqueue_scripts', 'maybe_enqueue', 11 ); - ob_start(); + $class->method( 'get_js_url' ) + ->willReturn( 'https://plausible.test/js/plausible.js' ); - $class->maybe_enqueue_main_script(); + wp_set_current_user( 1 ); + $user = wp_get_current_user(); + $user->add_role( 'administrator' ); - do_action( 'wp_head' ); + $class->maybe_enqueue_main_script(); - $output = ob_get_clean(); + global $wp_scripts; + $data = $wp_scripts->get_data( 'plausible-analytics', 'after' ); - $this->assertStringContainsString( 'window.plausible', $output ); - $this->assertStringContainsString( 'plausible.init', $output ); + $this->assertStringContainsString( 'window.plausible', implode( '', $data ) ); + $this->assertStringContainsString( 'plausible.init', implode( '', $data ) ); + } finally { + remove_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); + wp_set_current_user( null ); + } } /** diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 28930e6..96acdf2 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -164,6 +164,7 @@ public function testGetJsPath() { */ public function testGetDomain() { try { + delete_option( 'plausible_analytics_settings' ); $domain = Helpers::get_domain(); $this->assertEquals( 'example.org', $domain ); @@ -183,6 +184,7 @@ public function testGetDomain() { * @see Helpers::get_endpoint_url() */ public function testGetDataApiUrl() { + delete_option( 'plausible_analytics_settings' ); $url = Helpers::get_endpoint_url(); $this->assertEquals( 'https://plausible.io/api/event', $url ); From d83703c31d580c36f9ef3332338583cfe4387b0f Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:49:10 +0100 Subject: [PATCH 06/10] Fixed double JSON unslashing (fixes failed PHP 7.4 tests) --- src/Ajax.php | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/Ajax.php b/src/Ajax.php index be428e6..94f5a12 100644 --- a/src/Ajax.php +++ b/src/Ajax.php @@ -100,16 +100,31 @@ public function quit_wizard() { * @access public * */ - private function clean( $var ) { + private function clean( $var, $key = '' ) { // If the variable is an array, recursively apply the function to each element of the array. if ( is_array( $var ) ) { - return array_map( [ $this, 'clean' ], $var ); + $cleaned = []; + + foreach ( $var as $k => $v ) { + $cleaned[ $k ] = $this->clean( $v, $k ); + } + + return $cleaned; } // If the variable is a scalar value (string, integer, float, boolean). if ( is_scalar( $var ) ) { + /** + * If the variable is the options object, we only unslash it, but don't sanitize it yet. + * Sanitization will happen after json_decode. + */ + if ( $key === 'options' ) { + return wp_unslash( $var ); + } + // Parse the variable using the wp_parse_url function. $parsed = wp_parse_url( $var ); + // If the variable has a scheme (e.g. http:// or https://), sanitize the variable using the esc_url_raw function. if ( isset( $parsed['scheme'] ) ) { return esc_url_raw( wp_unslash( $var ), [ $parsed['scheme'] ] ); @@ -277,7 +292,7 @@ public function save_options() { wp_send_json_error( null, 403 ); } - $options = json_decode( stripslashes( $post_data['options'] ) ); + $options = json_decode( $post_data['options'] ); if ( empty( $options ) ) { Messages::set_error( __( 'No options found to save.', 'plausible-analytics' ) ); @@ -315,24 +330,27 @@ function ( $option ) { } foreach ( $options as $option ) { + $name = sanitize_text_field( $option->name ); + $value = $this->clean( $option->value ); + // Clean spaces - if ( is_string( $option->value ) ) { - $settings[ $option->name ] = trim( $option->value ); + if ( is_string( $value ) ) { + $settings[ $name ] = trim( $value ); } else { - $settings[ $option->name ] = $option->value; + $settings[ $name ] = $value; } // Validate Plugin Token if this is the Plugin Token field. - if ( $option->name === 'api_token' ) { - $this->validate_api_token( $option->value ); + if ( $name === 'api_token' ) { + $this->validate_api_token( $value ); - $additional = $this->maybe_render_additional_message( $option->name, $option->value ); + $additional = $this->maybe_render_additional_message( $name, $value ); - Messages::set_additional( $additional, $option->name ); + Messages::set_additional( $additional, $name ); } // Refresh Tracker ID if Domain Name has changed (e.g. after migration from staging to production) - if ( $option->name === 'domain_name' ) { + if ( $name === 'domain_name' ) { delete_option( 'plausible_analytics_tracker_id' ); } } From 77ba6c34b9aa75b53869d530b390603f3029a37a Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:23:35 +0100 Subject: [PATCH 07/10] Fixed: PHP 7.4 related error in Module::maybe_install_module. --- src/Admin/Module.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Admin/Module.php b/src/Admin/Module.php index 4087ca7..4126bf4 100644 --- a/src/Admin/Module.php +++ b/src/Admin/Module.php @@ -43,9 +43,12 @@ private function init() { * */ public function maybe_install_module( $old_settings, $settings ) { - if ( $settings['proxy_enabled'] === 'on' && $old_settings['proxy_enabled'] !== 'on' ) { + $settings_proxy = ( is_array( $settings ) && isset( $settings['proxy_enabled'] ) ) ? $settings['proxy_enabled'] : ''; + $old_proxy = ( is_array( $old_settings ) && isset( $old_settings['proxy_enabled'] ) ) ? $old_settings['proxy_enabled'] : ''; + + if ( $settings_proxy === 'on' && $old_proxy !== 'on' ) { $this->install(); - } elseif ( $settings['proxy_enabled'] === '' && $old_settings['proxy_enabled'] === 'on' ) { + } elseif ( $settings_proxy === '' && $old_proxy === 'on' ) { $this->uninstall(); } } @@ -196,7 +199,10 @@ public function maybe_enable_proxy( $settings, $old_settings ) { /** * No need to run this on each update run, or when the proxy is disabled. */ - if ( empty( $settings['proxy_enabled'] ) || ( $settings['proxy_enabled'] === 'on' && $old_settings['proxy_enabled'] === 'on' ) ) { + $settings_proxy = ( is_array( $settings ) && isset( $settings['proxy_enabled'] ) ) ? $settings['proxy_enabled'] : ( $settings['proxy_enabled'] ?? '' ); + $old_proxy = ( is_array( $old_settings ) && isset( $old_settings['proxy_enabled'] ) ) ? $old_settings['proxy_enabled'] : ''; + + if ( empty( $settings_proxy ) || ( $settings_proxy === 'on' && $old_proxy === 'on' ) ) { return $settings; } From efd092e33f74ef8c480c41c9be56d63c8e853ce8 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:29:52 +0100 Subject: [PATCH 08/10] Update src/Admin/Module.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/Admin/Module.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Admin/Module.php b/src/Admin/Module.php index 4126bf4..67a8eaa 100644 --- a/src/Admin/Module.php +++ b/src/Admin/Module.php @@ -199,7 +199,7 @@ public function maybe_enable_proxy( $settings, $old_settings ) { /** * No need to run this on each update run, or when the proxy is disabled. */ - $settings_proxy = ( is_array( $settings ) && isset( $settings['proxy_enabled'] ) ) ? $settings['proxy_enabled'] : ( $settings['proxy_enabled'] ?? '' ); + $settings_proxy = ( is_array( $settings ) && isset( $settings['proxy_enabled'] ) ) ? $settings['proxy_enabled'] : ''; $old_proxy = ( is_array( $old_settings ) && isset( $old_settings['proxy_enabled'] ) ) ? $old_settings['proxy_enabled'] : ''; if ( empty( $settings_proxy ) || ( $settings_proxy === 'on' && $old_proxy === 'on' ) ) { From f87acbaf97e9a08b637050333926b2a03b5ad047 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:35:02 +0100 Subject: [PATCH 09/10] Improved: Prevent notice in save_options() Fixed: patched minor XSS risk in clean() --- src/Ajax.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Ajax.php b/src/Ajax.php index 94f5a12..197cca4 100644 --- a/src/Ajax.php +++ b/src/Ajax.php @@ -127,7 +127,7 @@ private function clean( $var, $key = '' ) { // If the variable has a scheme (e.g. http:// or https://), sanitize the variable using the esc_url_raw function. if ( isset( $parsed['scheme'] ) ) { - return esc_url_raw( wp_unslash( $var ), [ $parsed['scheme'] ] ); + return esc_url_raw( wp_unslash( $var ) ); } // If the variable does not have a scheme, sanitize the variable using the sanitize_text_field function. @@ -317,10 +317,11 @@ function ( $option ) { ); if ( count( $input_array_elements ) > 0 ) { - $options = []; - $array_name = preg_replace( '/\[[0-9]+]/', '', $input_array_elements[0]->name ); - $options[0] = (object) []; - $options[0]->name = $array_name; + $options = []; + $array_name = preg_replace( '/\[[0-9]+]/', '', $input_array_elements[0]->name ); + $options[0] = (object) []; + $options[0]->name = $array_name; + $options[0]->value = []; foreach ( $input_array_elements as $input_array_element ) { if ( $input_array_element->value ) { From 65a8e166d4102ba01dc251bb1e7cc24b2ff05c60 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:37:27 +0100 Subject: [PATCH 10/10] Minor refactor. --- src/Admin/Module.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Admin/Module.php b/src/Admin/Module.php index 67a8eaa..b383b8b 100644 --- a/src/Admin/Module.php +++ b/src/Admin/Module.php @@ -199,10 +199,10 @@ public function maybe_enable_proxy( $settings, $old_settings ) { /** * No need to run this on each update run, or when the proxy is disabled. */ - $settings_proxy = ( is_array( $settings ) && isset( $settings['proxy_enabled'] ) ) ? $settings['proxy_enabled'] : ''; - $old_proxy = ( is_array( $old_settings ) && isset( $old_settings['proxy_enabled'] ) ) ? $old_settings['proxy_enabled'] : ''; + $new_proxy_setting = ( is_array( $settings ) && isset( $settings['proxy_enabled'] ) ) ? $settings['proxy_enabled'] : ''; + $old_proxy_setting = ( is_array( $old_settings ) && isset( $old_settings['proxy_enabled'] ) ) ? $old_settings['proxy_enabled'] : ''; - if ( empty( $settings_proxy ) || ( $settings_proxy === 'on' && $old_proxy === 'on' ) ) { + if ( empty( $new_proxy_setting ) || ( $new_proxy_setting === 'on' && $old_proxy_setting === 'on' ) ) { return $settings; }