diff --git a/composer.lock b/composer.lock index 188e3c4..e307bd1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f8c91318eaf88ba7f7a83e2835fe8631", + "content-hash": "5db1435350ebdcf673b3fd38fd095f3b", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.68", + "version": "3.3.72", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "8477d5100ffdddd316a3e51129d775d8854d8a77" + "reference": "66036fa0ef8168f7e5a3f7bf06dc88733e1d46db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/8477d5100ffdddd316a3e51129d775d8854d8a77", - "reference": "8477d5100ffdddd316a3e51129d775d8854d8a77", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/66036fa0ef8168f7e5a3f7bf06dc88733e1d46db", + "reference": "66036fa0ef8168f7e5a3f7bf06dc88733e1d46db", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.68" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.72" }, - "time": "2026-10-27T13:38:50+00:00" + "time": "2026-11-06T13:01:52+00:00" } ], "packages-dev": [ diff --git a/includes/classes/wp-maintenance-mode-admin.php b/includes/classes/wp-maintenance-mode-admin.php index aa9ccc9..bc4533d 100644 --- a/includes/classes/wp-maintenance-mode-admin.php +++ b/includes/classes/wp-maintenance-mode-admin.php @@ -384,6 +384,46 @@ public function display_plugin_settings() { } } + /** + * Whether a submitted settings tab has the shape the save handler expects: + * list fields hold arrays of scalars, every other field is a scalar. + * + * @param string $tab Settings tab slug. + * @param string|array|null $options Submitted options for the tab. + * @return bool + */ + private function is_valid_settings_payload( string $tab, $options ): bool { + if ( ! is_array( $options ) ) { + return false; + } + + // the classic Modules save maps over countdown_details unconditionally + if ( 'modules' === $tab && ! get_option( 'wpmm_new_look' ) && ! isset( $options['countdown_details'] ) ) { + return false; + } + + $list_fields = array( + 'general' => array( 'backend_role', 'frontend_role' ), + 'modules' => array( 'countdown_details' ), + 'bot' => array( 'messages', 'responses' ), + ); + $tab_lists = isset( $list_fields[ $tab ] ) ? $list_fields[ $tab ] : array(); + + foreach ( $options as $field => $value ) { + $is_list = in_array( $field, $tab_lists, true ); + if ( $is_list !== is_array( $value ) ) { + return false; + } + + $items = $is_list ? $value : array( $value ); + if ( count( array_filter( $items, 'is_scalar' ) ) !== count( $items ) ) { + return false; + } + } + + return true; + } + /** * Save settings * @@ -416,6 +456,10 @@ public function save_plugin_settings() { die( esc_html__( 'The tab slug must exist.', 'wp-maintenance-mode' ) ); } + if ( ! $this->is_valid_settings_payload( $tab, isset( $_POST['options'][ $tab ] ) ? $_POST['options'][ $tab ] : null ) ) { + wp_die( esc_html__( 'The submitted settings are invalid.', 'wp-maintenance-mode' ), '', array( 'response' => 400 ) ); + } + // Do some sanitizations switch ( $tab ) { case 'general': @@ -928,7 +972,14 @@ public function change_template_category() { * @return void */ public function toggle_gutenberg() { - if ( empty( $_POST['source'] ) ) { + // check capabilities + if ( ! current_user_can( wpmm_get_capability( 'settings' ) ) ) { + wp_send_json_error( __( 'You do not have access to this resource.', 'wp-maintenance-mode' ) ); + } + + // only the migration notices may toggle; other notice nonces reach every role. + $source = isset( $_POST['source'] ) && in_array( $_POST['source'], array( 'migration', 'rollback' ), true ) ? $_POST['source'] : ''; + if ( empty( $source ) ) { die( esc_html__( 'The source filed must not be empty.', 'wp-maintenance-mode' ) ); } @@ -938,7 +989,7 @@ public function toggle_gutenberg() { } // check nonce validation - if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'notice_nonce_' . $_POST['source'] ) ) { + if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'notice_nonce_' . $source ) ) { die( esc_html__( 'Security check.', 'wp-maintenance-mode' ) ); } diff --git a/tests/ajax-api-test.php b/tests/ajax-api-test.php index 22e56a4..2c5148f 100644 --- a/tests/ajax-api-test.php +++ b/tests/ajax-api-test.php @@ -358,8 +358,8 @@ public function test_toggle_gutenberg_flips_the_new_look_option() { delete_option( 'wpmm_new_look' ); delete_option( 'wpmm_migration_time' ); - $_POST['source'] = 'dashboard'; - $_POST['_wpnonce'] = wp_create_nonce( 'notice_nonce_dashboard' ); + $_POST['source'] = 'migration'; + $_POST['_wpnonce'] = wp_create_nonce( 'notice_nonce_migration' ); $response = $this->handle( 'wpmm_toggle_gutenberg' ); @@ -368,6 +368,21 @@ public function test_toggle_gutenberg_flips_the_new_look_option() { $this->assertNotEmpty( get_option( 'wpmm_migration_time' ) ); } + public function test_toggle_gutenberg_requires_capability_even_with_a_notice_nonce() { + $this->_setRole( 'subscriber' ); + update_option( 'wpmm_new_look', '1' ); + + // Every role sees the rebrand notice, so a subscriber holds its nonce. + $_POST['source'] = 'rebrand'; + $_POST['_wpnonce'] = wp_create_nonce( 'notice_nonce_rebrand' ); + + $response = $this->handle( 'wpmm_toggle_gutenberg' ); + + $this->assertFalse( $response['success'] ); + $this->assertSame( 'You do not have access to this resource.', $response['data'] ); + $this->assertSame( '1', get_option( 'wpmm_new_look' ) ); + } + /* * wpmm_subscribe (plugin newsletter, external HTTP) */ diff --git a/tests/settings-save-test.php b/tests/settings-save-test.php new file mode 100644 index 0000000..5d8257d --- /dev/null +++ b/tests/settings-save-test.php @@ -0,0 +1,162 @@ +> + */ + private $settings; + + public function set_up(): void { + parent::set_up(); + + $this->settings = WP_Maintenance_Mode::get_instance()->default_settings(); + update_option( 'wpmm_settings', $this->settings ); + update_option( 'wpmm_new_look', '0' ); + } + + public function tear_down(): void { + $_POST = array(); + wp_set_current_user( 0 ); + parent::tear_down(); + } + + /** + * Submit a settings tab form as an administrator. + * + * @param 'general'|'design'|'modules'|'bot' $tab Settings tab slug. + * @param array{options?: string|array} $fields Submitted fields besides nonce and tab. + * @return string The message the request ended with. + */ + private function save_settings( string $tab, array $fields ): string { + $admin = wpmm_test_boot_admin(); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $_POST = array_merge( + array( + '_wpnonce' => wp_create_nonce( 'tab-' . $tab ), + 'tab' => $tab, + ), + $fields + ); + + $redirect_catcher = function (): void { + // the handler ends with wp_safe_redirect() + exit + throw new WPDieException( 'redirected' ); + }; + add_filter( 'wp_redirect', $redirect_catcher ); + + try { + $admin->save_plugin_settings(); + $message = 'no response'; + } catch ( WPDieException $e ) { + $message = $e->getMessage(); + } finally { + remove_filter( 'wp_redirect', $redirect_catcher ); + } + + return $message; + } + + /** + * @return array + */ + public function malformed_payloads(): array { + return array( + 'options is a string' => array( 'general', array( 'options' => 'status' ) ), + 'options is an empty string' => array( 'general', array( 'options' => '' ) ), + 'options[general] is a string' => array( 'general', array( 'options' => array( 'general' => 'status' ) ) ), + 'options is missing' => array( 'general', array() ), + 'design options is a string' => array( 'design', array( 'options' => 'title' ) ), + 'options[design] is a string' => array( 'design', array( 'options' => array( 'design' => 'title' ) ) ), + ); + } + + /** + * @dataProvider malformed_payloads + * + * @param 'general'|'design' $tab Settings tab slug. + * @param array{options?: string|array{general?: string, design?: string}} $fields Submitted fields besides nonce and tab. + */ + public function test_malformed_payload_is_rejected_without_saving( string $tab, array $fields ): void { + $message = $this->save_settings( $tab, $fields ); + + $this->assertSame( 'The submitted settings are invalid.', $message ); + $this->assertSame( $this->settings, get_option( 'wpmm_settings' ) ); + } + + /** + * @return array>}> + */ + public function malformed_fields(): array { + return array( + 'heading_color is an array' => array( 'design', 'heading_color', array( '#000000' ) ), + 'bg_custom is an array' => array( 'design', 'bg_custom', array( 'https://example.org/bg.jpg' ) ), + 'backend_role is a string' => array( 'general', 'backend_role', 'administrator' ), + 'frontend_role is a string' => array( 'general', 'frontend_role', 'editor' ), + 'exclude is an array' => array( 'general', 'exclude', array( 'feed' ) ), + 'redirection is an array' => array( 'general', 'redirection', array( 'https://example.org' ) ), + 'countdown_details is a string' => array( 'modules', 'countdown_details', '1' ), + 'countdown day is an array' => array( 'modules', 'countdown_details', array( 'days' => array( '1' ), 'hours' => '1', 'minutes' => '0' ) ), + 'bot messages is a string' => array( 'bot', 'messages', 'hello' ), + ); + } + + /** + * Submit an otherwise complete form for the tab with one field of the wrong type. + * + * @dataProvider malformed_fields + * + * @param 'general'|'design'|'modules'|'bot' $tab Settings tab slug. + * @param string $field Field name inside the tab. + * @param string|array> $value Submitted value. + */ + public function test_malformed_field_is_rejected_without_saving( string $tab, string $field, $value ): void { + // the stored general defaults keep exclude as a list, while the form posts a string + $form = 'general' === $tab ? array( 'status' => '0' ) : $this->settings[ $tab ]; + $form[ $field ] = $value; + + $message = $this->save_settings( $tab, array( 'options' => array( $tab => $form ) ) ); + + $this->assertSame( 'The submitted settings are invalid.', $message ); + $this->assertSame( $this->settings, get_option( 'wpmm_settings' ) ); + } + + public function test_classic_modules_save_without_countdown_details_is_rejected(): void { + $form = $this->settings['modules']; + unset( $form['countdown_details'] ); + + $message = $this->save_settings( 'modules', array( 'options' => array( 'modules' => $form ) ) ); + + $this->assertSame( 'The submitted settings are invalid.', $message ); + $this->assertSame( $this->settings, get_option( 'wpmm_settings' ) ); + } + + public function test_new_look_modules_save_without_countdown_details_is_saved(): void { + update_option( 'wpmm_new_look', '1' ); + $form = $this->settings['modules']; + unset( $form['countdown_details'] ); + $form['ga_status'] = '1'; + + $message = $this->save_settings( 'modules', array( 'options' => array( 'modules' => $form ) ) ); + + $this->assertSame( 'redirected', $message ); + $this->assertSame( 1, get_option( 'wpmm_settings' )['modules']['ga_status'] ); + } + + public function test_general_form_save_stores_status(): void { + $message = $this->save_settings( 'general', array( 'options' => array( 'general' => array( 'status' => '1' ) ) ) ); + + $this->assertSame( 'redirected', $message ); + $this->assertSame( 1, get_option( 'wpmm_settings' )['general']['status'] ); + } +}