Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.34% covered (warning)
72.34%
68 / 94
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Settings
73.91% covered (warning)
73.91%
68 / 92
44.44% covered (danger)
44.44%
4 / 9
61.30
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 get_settings
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 update_settings
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
14.30
 sanitize_ip_lists
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 invalid_param
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 get_monitor_notifications
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
5.31
 get_waf_keys
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 error
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
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
8namespace Automattic\Jetpack\Protect;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Connection\SSO\Helpers as SSO_Helpers;
12use Automattic\Jetpack\Modules;
13use Automattic\Jetpack\Monitor\Monitor;
14use Automattic\Jetpack\Protect\Sections\Firewall;
15use Automattic\Jetpack\Waf\REST_Controller as Waf_REST_Controller;
16use Automattic\Jetpack\Waf\Waf_Runner;
17use WP_Error;
18use WP_REST_Request;
19use WP_REST_Server;
20
21if ( ! 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 */
30class 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}