Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
72.34% |
68 / 94 |
|
44.44% |
4 / 9 |
CRAP | |
0.00% |
0 / 1 |
| Settings | |
73.91% |
68 / 92 |
|
44.44% |
4 / 9 |
61.30 | |
0.00% |
0 / 1 |
| init | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| register_routes | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
2 | |||
| get_settings | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
| update_settings | |
88.46% |
23 / 26 |
|
0.00% |
0 / 1 |
14.30 | |||
| sanitize_ip_lists | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
| invalid_param | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| get_monitor_notifications | |
76.92% |
10 / 13 |
|
0.00% |
0 / 1 |
5.31 | |||
| get_waf_keys | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| error | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Protect dashboard: the settings its toggles read and save. |
| 4 | * |
| 5 | * @package automattic/jetpack-protect |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Protect; |
| 9 | |
| 10 | use Automattic\Jetpack\Connection\Manager as Connection_Manager; |
| 11 | use Automattic\Jetpack\Connection\SSO\Helpers as SSO_Helpers; |
| 12 | use Automattic\Jetpack\Modules; |
| 13 | use Automattic\Jetpack\Monitor\Monitor; |
| 14 | use Automattic\Jetpack\Protect\Sections\Firewall; |
| 15 | use Automattic\Jetpack\Waf\REST_Controller as Waf_REST_Controller; |
| 16 | use Automattic\Jetpack\Waf\Waf_Runner; |
| 17 | use WP_Error; |
| 18 | use WP_REST_Request; |
| 19 | use WP_REST_Server; |
| 20 | |
| 21 | if ( ! defined( 'ABSPATH' ) ) { |
| 22 | exit( 0 ); |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * A flat settings route covering only the dashboard's keys, so the page works without the Jetpack plugin's `jetpack/v4/settings`. |
| 27 | * |
| 28 | * @since $$next-version$$ |
| 29 | */ |
| 30 | class Settings { |
| 31 | |
| 32 | /** |
| 33 | * Modules the dashboard can switch on and off. |
| 34 | * |
| 35 | * @var string[] |
| 36 | */ |
| 37 | const MODULES = array( 'waf', 'protect', 'account-protection', 'monitor', 'sso' ); |
| 38 | |
| 39 | /** |
| 40 | * Monitor's per-user email setting, which lives on WordPress.com. |
| 41 | * |
| 42 | * @var string |
| 43 | */ |
| 44 | const MONITOR_NOTIFICATIONS = 'monitor_receive_notifications'; |
| 45 | |
| 46 | /** |
| 47 | * Prefix of the per-user transient set while WordPress.com can't answer for that setting. |
| 48 | * |
| 49 | * @var string |
| 50 | */ |
| 51 | const MONITOR_NOTIFICATIONS_FAILED = 'jetpack_protect_monitor_notifications_failed_'; |
| 52 | |
| 53 | /** |
| 54 | * SSO's options, saved locally as 0 or 1, each with the SSO helper that reads its value in effect. |
| 55 | * |
| 56 | * @var string[] |
| 57 | */ |
| 58 | const SSO_OPTIONS = array( |
| 59 | 'jetpack_sso_match_by_email' => 'match_by_email', |
| 60 | 'jetpack_sso_require_two_step' => 'is_two_step_required', |
| 61 | ); |
| 62 | |
| 63 | /** |
| 64 | * Firewall options, saved through the WAF package's own handler. |
| 65 | * |
| 66 | * @var string[] |
| 67 | */ |
| 68 | const WAF_OPTIONS = array( |
| 69 | 'jetpack_waf_automatic_rules', |
| 70 | 'jetpack_waf_ip_allow_list', |
| 71 | 'jetpack_waf_ip_allow_list_enabled', |
| 72 | 'jetpack_waf_ip_block_list', |
| 73 | 'jetpack_waf_ip_block_list_enabled', |
| 74 | 'jetpack_waf_share_data', |
| 75 | 'jetpack_waf_share_debug_data', |
| 76 | ); |
| 77 | |
| 78 | /** |
| 79 | * Wire the hooks. |
| 80 | * |
| 81 | * @return void |
| 82 | */ |
| 83 | public static function init() { |
| 84 | add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Register the settings route. |
| 89 | * |
| 90 | * @return void |
| 91 | */ |
| 92 | public static function register_routes() { |
| 93 | register_rest_route( |
| 94 | 'jetpack/v4', |
| 95 | '/protect-dashboard/settings', |
| 96 | array( |
| 97 | array( |
| 98 | 'methods' => WP_REST_Server::READABLE, |
| 99 | 'callback' => array( __CLASS__, 'get_settings' ), |
| 100 | 'permission_callback' => array( Dashboard::class, 'can_manage' ), |
| 101 | ), |
| 102 | array( |
| 103 | 'methods' => WP_REST_Server::EDITABLE, |
| 104 | 'callback' => array( __CLASS__, 'update_settings' ), |
| 105 | 'permission_callback' => array( Dashboard::class, 'can_manage' ), |
| 106 | ), |
| 107 | ) |
| 108 | ); |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Every supported key and its current value; keys for features this site can't run are left out. |
| 113 | * |
| 114 | * @return array |
| 115 | */ |
| 116 | public static function get_settings() { |
| 117 | $modules = new Modules(); |
| 118 | $settings = array(); |
| 119 | |
| 120 | foreach ( self::MODULES as $module ) { |
| 121 | if ( $modules->is_module( $module ) ) { |
| 122 | $settings[ $module ] = $modules->is_active( $module ); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | if ( ! empty( $settings['monitor'] ) ) { |
| 127 | $settings[ self::MONITOR_NOTIFICATIONS ] = self::get_monitor_notifications(); |
| 128 | } |
| 129 | |
| 130 | if ( isset( $settings['sso'] ) ) { |
| 131 | // The helpers apply the constant and filters that can force a value, which the dashboard then shows as locked. |
| 132 | foreach ( self::SSO_OPTIONS as $option => $helper ) { |
| 133 | $settings[ $option ] = (bool) call_user_func( array( SSO_Helpers::class, $helper ) ); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | $settings = array_merge( $settings, self::get_waf_keys() ); |
| 138 | |
| 139 | return $settings; |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Save the given keys, then return every setting. |
| 144 | * |
| 145 | * @param WP_REST_Request $request The request; its JSON body is the patch. |
| 146 | * @return array|WP_Error |
| 147 | */ |
| 148 | public static function update_settings( $request ) { |
| 149 | $patch = self::sanitize_ip_lists( (array) $request->get_json_params() ); |
| 150 | if ( $patch instanceof WP_Error ) { |
| 151 | return $patch; |
| 152 | } |
| 153 | $modules = new Modules(); |
| 154 | |
| 155 | foreach ( $patch as $key => $value ) { |
| 156 | if ( in_array( $key, self::MODULES, true ) ) { |
| 157 | if ( ! $modules->is_module( $key ) ) { |
| 158 | return self::error( 'module_unavailable', $key ); |
| 159 | } |
| 160 | if ( $value ) { |
| 161 | $modules->activate( $key, false, false ); |
| 162 | } else { |
| 163 | $modules->deactivate( $key ); |
| 164 | } |
| 165 | // deactivate() returns false when nothing changed, and a filter can force a module on, so check the outcome. |
| 166 | if ( $modules->is_active( $key ) !== (bool) $value ) { |
| 167 | return self::error( 'module_update_failed', $key ); |
| 168 | } |
| 169 | } elseif ( isset( self::SSO_OPTIONS[ $key ] ) && $modules->is_module( 'sso' ) ) { |
| 170 | // An integer, because update_option() won't create an option whose value is `false`. |
| 171 | update_option( $key, (int) (bool) $value ); |
| 172 | } elseif ( self::MONITOR_NOTIFICATIONS === $key ) { |
| 173 | if ( true !== Monitor::set_notifications( (bool) $value ) ) { |
| 174 | return self::error( 'monitor_notifications_failed', $key ); |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | $waf_patch = array_intersect_key( $patch, self::get_waf_keys() ); |
| 180 | if ( $waf_patch && class_exists( Waf_REST_Controller::class ) ) { |
| 181 | $waf_request = new WP_REST_Request( 'POST' ); |
| 182 | $waf_request->set_body_params( $waf_patch ); |
| 183 | $result = Waf_REST_Controller::update_waf( $waf_request ); |
| 184 | if ( is_wp_error( $result ) ) { |
| 185 | return $result; |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | return self::get_settings(); |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Check the IP lists as `jetpack/v4/settings` did, before anything is saved, and return the patch with them escaped. |
| 194 | * |
| 195 | * @param array $patch The requested changes. |
| 196 | * @return array|WP_Error |
| 197 | */ |
| 198 | private static function sanitize_ip_lists( $patch ) { |
| 199 | foreach ( array( 'jetpack_waf_ip_allow_list', 'jetpack_waf_ip_block_list' ) as $key ) { |
| 200 | if ( ! array_key_exists( $key, $patch ) ) { |
| 201 | continue; |
| 202 | } |
| 203 | if ( ! is_string( $patch[ $key ] ) ) { |
| 204 | /* translators: %s is a setting name. */ |
| 205 | return self::invalid_param( $key, sprintf( __( '%s must be a string.', 'jetpack-protect-pkg' ), $key ) ); |
| 206 | } |
| 207 | // The firewall reads the stored copy, so check the list as it will be stored. |
| 208 | $patch[ $key ] = esc_textarea( $patch[ $key ] ); |
| 209 | if ( 'jetpack_waf_ip_block_list' === $key ) { |
| 210 | $valid = Firewall::validate_ip_block_list( $patch[ $key ] ); |
| 211 | if ( is_wp_error( $valid ) ) { |
| 212 | return self::invalid_param( $key, $valid->get_error_message() ); |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | return $patch; |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * A 400 error shaped like core's, whose `params` the dashboard shows. |
| 221 | * |
| 222 | * @param string $key The setting. |
| 223 | * @param string $message Why it was refused. |
| 224 | * @return WP_Error |
| 225 | */ |
| 226 | private static function invalid_param( $key, $message ) { |
| 227 | return new WP_Error( |
| 228 | 'rest_invalid_param', |
| 229 | /* translators: %s is a setting name. */ |
| 230 | sprintf( __( 'Invalid parameter(s): %s', 'jetpack-protect-pkg' ), $key ), |
| 231 | array( |
| 232 | 'status' => 400, |
| 233 | 'params' => array( $key => $message ), |
| 234 | ) |
| 235 | ); |
| 236 | } |
| 237 | |
| 238 | /** |
| 239 | * Whether the current user gets downtime emails: the local copy, or else WordPress.com's answer, which is then saved. |
| 240 | * |
| 241 | * @return bool |
| 242 | */ |
| 243 | private static function get_monitor_notifications() { |
| 244 | $user_id = get_current_user_id(); |
| 245 | $value = get_option( Monitor::notifications_copy_option_name( $user_id ), 'does_not_exist' ); |
| 246 | if ( 'does_not_exist' !== $value ) { |
| 247 | return (bool) $value; |
| 248 | } |
| 249 | |
| 250 | $failed_key = self::MONITOR_NOTIFICATIONS_FAILED . $user_id; |
| 251 | if ( ! ( new Connection_Manager() )->is_user_connected() || get_transient( $failed_key ) ) { |
| 252 | return false; |
| 253 | } |
| 254 | |
| 255 | $value = Monitor::user_receives_notifications(); |
| 256 | // Not saved, since the user may not be connected yet; the transient only stops a remote call on every load. |
| 257 | if ( is_wp_error( $value ) ) { |
| 258 | set_transient( $failed_key, 1, 5 * MINUTE_IN_SECONDS ); |
| 259 | return false; |
| 260 | } |
| 261 | |
| 262 | Monitor::save_notifications_copy( $value ); |
| 263 | return (bool) $value; |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * The firewall settings the dashboard edits, with their values; get_config() also returns server paths. |
| 268 | * |
| 269 | * @return array |
| 270 | */ |
| 271 | private static function get_waf_keys() { |
| 272 | if ( ! class_exists( Waf_Runner::class ) ) { |
| 273 | return array(); |
| 274 | } |
| 275 | return array_intersect_key( Waf_Runner::get_config(), array_flip( self::WAF_OPTIONS ) ); |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * A 400 error naming the key that could not be saved. |
| 280 | * |
| 281 | * @param string $code Error code. |
| 282 | * @param string $key The setting. |
| 283 | * @return WP_Error |
| 284 | */ |
| 285 | private static function error( $code, $key ) { |
| 286 | /* translators: %s is a setting name. */ |
| 287 | $message = sprintf( __( 'The "%s" setting couldn’t be saved.', 'jetpack-protect-pkg' ), $key ); |
| 288 | return new WP_Error( $code, $message, array( 'status' => 400 ) ); |
| 289 | } |
| 290 | } |