Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 226
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 2
WPCOMSH_Support_Session_Detect
0.00% covered (danger)
0.00%
0 / 195
0.00% covered (danger)
0.00%
0 / 10
2450
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 need_to_detect
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
42
 has_detection_result
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_probably_support_session
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 is_valid_detection_result
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_detection_result
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 detect_support_session_sso_success
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
156
 handle_detection_redirect
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
90
 handle_detection_requests
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
210
 print_detection_ui
0.00% covered (danger)
0.00%
0 / 104
0.00% covered (danger)
0.00%
0 / 1
2
WPCOMSH_Support_Session_Safety
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 5
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 enable_writing_support_session_css_rules
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 hide_admin_tos_blurbs
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 stop_updating_jetpack_tos_agreed_option
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 remove_jetpack_wpcom_tos_tool
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * This file provides support session detection and safety.
4 * Support session "safety" means avoiding certain actions on behalf of users (e.g., accepting ToS).
5 *
6 * @package automattic/wpcomsh
7 */
8// phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
9
10if ( defined( 'AT_PROXIED_REQUEST' ) && AT_PROXIED_REQUEST ) {
11    if ( ! WPCOMSH_Support_Session_Detect::has_detection_result() ) {
12        new WPCOMSH_Support_Session_Detect();
13    }
14
15    if ( WPCOMSH_Support_Session_Detect::is_probably_support_session() ) {
16        new WPCOMSH_Support_Session_Safety();
17    }
18}
19
20/**
21 * Detects the presence of a support session through Jetpack SSO
22 * or a client-side check when SSO is disabled or the Jetpack connection is broken.
23 *
24 * @phan-constructor-used-for-side-effects
25 */
26class WPCOMSH_Support_Session_Detect {
27    const COOKIE_NAME                  = '_wpcomsh_support_session_detected';
28    const DETECTION_URI                = '/_wpcomsh_detect_support_session';
29    const NONCE_ACTION                 = 'support-session-detect-get';
30    const NONCE_ACTION_POST            = 'support-session-detect-post';
31    const NONCE_NAME                   = 'nonce';
32    const LOGIN_PATH                   = '/wp-login.php';
33    const QUERY_PARAM_TO_SHORT_CIRCUIT = 'disable-support-session-detection';
34    const EMERGENCY_LOGIN_PATH         = '/wp-login.php?' . self::QUERY_PARAM_TO_SHORT_CIRCUIT;
35
36    /**
37     * Constructor.
38     */
39    public function __construct() {
40        // Detect support session on WordPress.com SSO success
41        add_action( 'muplugins_loaded', array( __CLASS__, 'detect_support_session_sso_success' ) );
42
43        // Detect support session via client-side check when both of the following are true:
44        // - User is not logged in
45        // - Jetpack is disconnected or Jetpack SSO is disabled
46        add_action( 'login_init', array( __CLASS__, 'handle_detection_redirect' ), -1 );
47        add_action( 'plugins_loaded', array( __CLASS__, 'handle_detection_requests' ), -1 );
48    }
49
50    /**
51     * Answers whether we need to detect whether the request is probably part of a support session.
52     *
53     * NOTE: This method is marked private because it is for internal use and can only be called
54     * safely after pluggable functions have been declared.
55     *
56     * @return bool
57     */
58    private static function need_to_detect() {
59        return (
60            ! is_user_logged_in() &&
61            ! static::has_detection_result() &&
62            ! ( class_exists( 'Jetpack' ) && Jetpack::is_connection_ready() && Jetpack::is_module_active( 'sso' ) ) &&
63            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Query parameters are used to categorize requests, nonce verification is done elsewhere.
64            ! isset( $_GET[ static::QUERY_PARAM_TO_SHORT_CIRCUIT ] )
65        );
66    }
67
68    /**
69     * Answers whether we already have a detection result
70     */
71    public static function has_detection_result() {
72        return isset( $_COOKIE[ static::COOKIE_NAME ] );
73    }
74
75    /**
76     * Answers whether we think the request is probably part of a support session
77     *
78     * @return bool
79     */
80    public static function is_probably_support_session() {
81        if ( isset( $_COOKIE[ static::COOKIE_NAME ] ) ) {
82            return 'true' === $_COOKIE[ static::COOKIE_NAME ];
83        }
84        return false;
85    }
86
87    /**
88     * Answers whether a value is a valid detection result
89     *
90     * @param mixed $candidate_result the value to be validated.
91     * @return bool
92     */
93    public static function is_valid_detection_result( $candidate_result ) {
94        return in_array( $candidate_result, array( 'true', 'false' ), true );
95    }
96
97    /**
98     * Saves the detection result (in a cookie)
99     *
100     * @param string $result the cookie value.
101     * @param int    $expires cookie expiration time.
102     */
103    public static function set_detection_result( $result, $expires = 0 ) {
104        if ( static::is_valid_detection_result( $result ) ) {
105            // TODO: Consider clearing this cookie on logout
106            setcookie(
107                static::COOKIE_NAME,
108                $result,
109                array(
110                    'path'     => '/',
111                    'expires'  => $expires,
112                    'secure'   => true,
113                    'httponly' => true,
114                    // Default to Strict SameSite setting until we have a reason to relax it
115                    'samesite' => 'Strict',
116                )
117            );
118        } else {
119            error_log( __CLASS__ . ": unexpected detection result '$result'" ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
120        }
121    }
122
123    /**
124     * Looks for an is_support_session flag on a WordPress.com SSO success request
125     */
126    public static function detect_support_session_sso_success() {
127        /**
128         * TODO: Review nonce verification procedures.
129         * This code works in the connection flow with a support session and makes sure that the support engineer
130         * is not accepting the ToS on the user's behalf. The detection is based on Jetpack SSO fields in the request.
131         * Jetpack handles nonce verification in this particular flow.
132         * Could we switch from 'muplugins_loaded' to 'jetpack_sso_handle_login' so this runs after Jetpack's nonce verification happens?
133         */
134        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- See above.
135        // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
136        $login_path = '/wp-login.php?';
137        if (
138            isset( $_SERVER['REQUEST_URI'] )
139                && 0 === strncmp( wp_unslash( $_SERVER['REQUEST_URI'] ), $login_path, strlen( $login_path ) )
140                && isset( $_SERVER['REQUEST_METHOD'] )
141                && 'GET' === $_SERVER['REQUEST_METHOD']
142                && isset( $_GET['action'] )
143                && 'jetpack-sso' === $_GET['action']
144                && isset( $_GET['result'] )
145                && 'success' === $_GET['result']
146        ) {
147            $is_probably_support_session =
148                isset( $_GET['is_support_session'] ) ? 'true' : 'false';
149
150            $expires = 0;
151            if ( isset( $_GET['expires'] ) && ctype_digit( wp_unslash( $_GET['expires'] ) ) ) {
152                $expires = time() + wp_unslash( $_GET['expires'] );
153            }
154
155            static::set_detection_result( $is_probably_support_session, $expires );
156        }
157        // phpcs:enable WordPress.Security.NonceVerification.Recommended
158        // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
159    }
160
161    /**
162     * Redirects unauthenticated wp-login requests to a client-side detection page
163     * when Jetpack is disconnected or SSO is disabled.
164     */
165    public static function handle_detection_redirect() {
166        if ( ! static::need_to_detect() ) {
167            return;
168        }
169
170        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- No change is being made to the site. We're only redirecting to an interstitial page.
171        // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
172        $is_simple_login_page_request =
173            isset( $_SERVER['REQUEST_METHOD'] )
174                && 'GET' === $_SERVER['REQUEST_METHOD']
175                && isset( $_SERVER['REQUEST_URI'] )
176                && static::LOGIN_PATH === wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH )
177                && ( empty( $_GET['action'] ) || 'jetpack-sso' !== $_GET['action'] );
178
179        if ( isset( $_SERVER['REQUEST_URI'] ) && $is_simple_login_page_request ) {
180            // After detection, we will redirect to this login URL.
181            // Add a query param to short-circuit detection so that if something goes wrong
182            // we do not end up in a login->detect redirect loop.
183            $destination_login_uri = add_query_arg(
184                static::QUERY_PARAM_TO_SHORT_CIRCUIT,
185                // empty value because this query param is just a flag
186                '',
187                wp_unslash( $_SERVER['REQUEST_URI'] )
188            );
189
190            // phpcs:enable WordPress.Security.NonceVerification.Recommended
191            // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
192            $detection_uri = add_query_arg(
193                array(
194                    'redirect' => rawurlencode( $destination_login_uri ),
195                    'nonce'    => wp_create_nonce( static::NONCE_ACTION ),
196                ),
197                static::DETECTION_URI
198            );
199            wp_redirect( $detection_uri ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
200            die( 0 );
201        }
202    }
203
204    /**
205     * Handles GETs and POSTs to the client-side support session detection page
206     */
207    public static function handle_detection_requests() {
208        // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
209        if (
210            isset( $_SERVER['REQUEST_URI'] )
211                && 0 !== strncmp(
212                    wp_unslash( $_SERVER['REQUEST_URI'] ),
213                    static::DETECTION_URI,
214                    strlen( static::DETECTION_URI )
215                )
216        ) {
217            return;
218        }
219
220        if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'GET' === $_SERVER['REQUEST_METHOD'] ) {
221
222            $nonce = wp_unslash( $_GET['nonce'] ?? '' );
223            if ( ! wp_verify_nonce( $nonce, static::NONCE_ACTION ) ) {
224
225                // phpcs:ignore -- Using an i18n call without a domain to use WordPress translations.
226                wp_die( __( 'Sorry, you are not allowed to access this page.' ) );
227            }
228
229            static::print_detection_ui();
230            die( 0 );
231        }
232
233        if ( 'POST' === $_SERVER['REQUEST_METHOD'] ) {
234            $nonce = wp_unslash( $_POST['nonce'] ?? '' );
235            if ( ! wp_verify_nonce( $nonce, static::NONCE_ACTION_POST ) ) {
236
237                // phpcs:ignore -- Using an i18n call without a domain to use WordPress translations.
238                wp_die( __( 'Sorry, you are not allowed to access this page.' ) );
239            }
240
241            if ( isset( $_POST['result'] ) && static::is_valid_detection_result( wp_unslash( $_POST['result'] ) ) ) {
242                static::set_detection_result( wp_unslash( $_POST['result'] ) );
243            }
244
245            // Return to original login path
246            $redirect = isset( $_GET['redirect'] ) ? wp_unslash( $_GET['redirect'] ) : null;
247            if (
248                ! is_string( $redirect )
249                    || '/' !== substr( $redirect, 0, 1 )
250                    || static::LOGIN_PATH !== wp_parse_url( $redirect, PHP_URL_PATH )
251            ) {
252                // Use "emergency" login path to avoid infinite login->detect redirect loop
253                $redirect = static::EMERGENCY_LOGIN_PATH;
254            }
255            wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
256            die( 0 );
257        }
258        // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
259    }
260
261    /**
262     * Prints a page to attempt support session detection on the client side,
263     * since it is the browser that may own either support session cookies
264     * or an extension-managed support session.
265     */
266    public static function print_detection_ui() {
267        ?>
268        <!DOCTYPE html>
269        <html>
270            <head>
271                <meta charset="UTF-8">
272                <title>Detect Support Session</title>
273                <style>
274                    body {
275                        /* add enough padding to render below the support session status overlay */
276                        padding: 57px;
277                        display: flex;
278                        flex-direction: column;
279                        align-items: center;
280                        font-size: larger;
281                        font-family: sans-serif;
282                    }
283
284                    body > * {
285                        margin: 42px;
286                    }
287
288                    #error-report {
289                        color: red;
290                    }
291
292                    #escape-hatch {
293                        background: lavender;
294                        padding: 0 13px;
295                        width: min( 37em, 90vw );
296                        font-size: 80%;
297                    }
298                </style>
299            </head>
300            <body>
301                <p>Asking WordPress.com whether we are in a support session...</p>
302                <div id="escape-hatch">
303                    <p>This check is for Automatticians only and should normally complete in a few seconds.</p>
304                    <p>
305                        We normally detect support sessions via WordPress.com SSO and only
306                        resort to this check when SSO is disabled or Jetpack is disconnected.
307                    </p>
308                    <p>If you encounter an error or the page appears to be stalled, please do the following:</p>
309                    <ul>
310                        <li>Check the dev tools console for errors</li>
311                        <li>Let us know on the Atomic Requests P2</li>
312                        <li>
313                            Continue login without support session detection by clicking
314                            <a target="_blank" href="<?php echo esc_url( static::EMERGENCY_LOGIN_PATH ); ?>">here</a>
315                        </li>
316                    </ul>
317                </div>
318                <form id="result-form" method="POST">
319                    <input id="result-field" type="hidden" name="result" value="">
320                    <?php wp_nonce_field( static::NONCE_ACTION_POST, 'nonce' ); ?>
321                </form>
322                <script>
323                    function wpcomshHandleFailure( message ) {
324                        if ( ! message ) {
325                            message = 'Unknown error';
326                        }
327                        console.error( message );
328
329                        var errorElement = document.createElement( 'p' );
330                        errorElement.id = 'error-report';
331                        errorElement.textContent = 'Error: ' + message;
332
333                        var escapeHatchElement = document.getElementById( 'escape-hatch' );
334                        escapeHatchElement.parentNode.insertBefore( errorElement, escapeHatchElement );
335                    }
336
337                    function wpcomshPostDetectionResult( resultValue ) {
338                        var resultForm = document.getElementById( 'result-form' );
339                        var resultField = document.getElementById( 'result-field' );
340
341                        resultField.value = resultValue;
342                        resultForm.submit();
343                    }
344                    
345                    function wpcomshHandleReadyStateChange() {
346                        if ( XMLHttpRequest.DONE !== xhr.readyState ) {
347                            return;
348                        }
349
350                        if ( 200 !== xhr.status ) {
351                            wpcomshHandleFailure( 'Unexpected HTTP status code: ' + xhr.status );
352                            return;
353                        }
354
355                        if ( ! xhr.responseText ) {
356                            wpcomshHandleFailure( 'Empty response' );
357                            return;
358                        }
359
360                        var parsedResponse = JSON.parse( xhr.responseText );
361                        if ( typeof parsedResponse === 'boolean' ) {
362                            wpcomshPostDetectionResult( parsedResponse.toString() );
363                        } else {
364                            wpcomHandleFailure( 'Unexpected result type: ' + ( typeof parsedResponse ) );
365                        }
366                    }
367
368                    // Handle exceptional errors in one place rather than using scattered try/catch blocks
369                    window.addEventListener( 'error', function ( errorEvent ) {
370                        var message = errorEvent.message || 'Unknown unhandled error';
371                        wpcomshHandleFailure( message );
372                    } );
373
374                    // Use basic XMLHttpRequest to avoid errors in older browsers
375                    var xhr = new XMLHttpRequest();
376                    xhr.open(
377                        'POST',
378                        'https://public-api.wordpress.com/wpcom/v2/atomic/is-probably-support-session',
379                    );
380                    xhr.onreadystatechange = wpcomshHandleReadyStateChange;
381                    xhr.timeout = 15 * 1000;
382                    xhr.ontimeout = function () {
383                        wpcomshHandleFailure( 'Support session detection timed out' );
384                    };
385                    xhr.send();
386                </script>
387            </body>
388        </html>
389        <?php
390    }
391}
392
393/**
394 * Attempts to hide ToS acceptance UI and prevent logging user ToS acceptance
395 * while in a support session.
396 *
397 * @phan-constructor-used-for-side-effects
398 */
399class WPCOMSH_Support_Session_Safety {
400
401    /**
402     * Constructor.
403     */
404    public function __construct() {
405        add_action( 'admin_body_class', array( __CLASS__, 'enable_writing_support_session_css_rules' ) );
406        // Use `admin_print_styles` instead of `admin_enqueue_scripts` to match
407        // how Jetpack enqueues its styles (so we can attach inline styles).
408        // Use the priority right before 20, when WordPress prints admin styles,
409        // to give us the best chance of adding inline styles _after_ Jetpack enqueues its styles.
410        add_action( 'admin_print_styles', array( __CLASS__, 'hide_admin_tos_blurbs' ), 19 );
411
412        // Stop Jetpack from saving an option to reflect ToS acceptance
413        // Ref: https://github.com/Automattic/jetpack/blob/7054c9a46cdd054cf45c04c85fb5464d179bafb6/projects/packages/terms-of-service/src/class-terms-of-service.php#L21
414        add_filter(
415            'pre_update_option_jetpack_tos_agreed',
416            array( __CLASS__, 'stop_updating_jetpack_tos_agreed_option' ),
417            10,
418            2
419        );
420
421        // Stop Jetpack from logging ToS acceptance using the wpcom public-api
422        // TODO: Is there anything we can do to stop the actual request if this measure is ineffective?
423        // Ref: https://github.com/Automattic/jetpack/blob/26db3e436ccca3e16a62d02d95e792a81f38a1e4/projects/plugins/jetpack/modules/module-extras.php#L73
424        add_filter( 'jetpack_tools_to_include', array( __CLASS__, 'remove_jetpack_wpcom_tos_tool' ) );
425    }
426
427    /**
428     * Adds a support-session class to admin body tags so we can write CSS rules
429     * that apply only for support sessions.
430     *
431     * @param string $body_element_classes class strings for the body element.
432     * @return string
433     */
434    public static function enable_writing_support_session_css_rules( $body_element_classes ) {
435        $body_element_classes .= ' support-session';
436        return $body_element_classes;
437    }
438
439    /**
440     * Adds inline styles to hide ToS blurbs
441     */
442    public static function hide_admin_tos_blurbs() {
443        // Stop showing ToS blurbs during support sessions
444        wp_add_inline_style(
445            'jetpack',
446            '
447            .support-session .jp-banner__tos-blurb,
448            .support-session .jp-connect-full__tos-blurb {
449                visibility: hidden !important;
450            }
451        '
452        );
453    }
454
455    /**
456     * Prevent updates to the `jetpack_tos_agreed` option
457     *
458     * @param string $new_value the new value to be set.
459     * @param string $old_value the old value to be set instead.
460     *
461     * @return string the old option value
462     */
463    public static function stop_updating_jetpack_tos_agreed_option( $new_value, $old_value ) {
464        // return old value to stop saving a new option value
465        return $old_value;
466    }
467
468    /**
469     * Filter out the wpcom-tos tool from the tools Jetpack wants to load
470     *
471     * @param array $jetpack_tools the array of Jetpack tools being loaded.
472     * @return array the Jetpack tools without the wpcom-tos tool
473     */
474    public static function remove_jetpack_wpcom_tos_tool( $jetpack_tools ) {
475        $filtered_jetpack_tools = array_filter(
476            $jetpack_tools,
477            function ( $tool_path ) {
478                $file_to_exclude = 'wpcom-tos.php';
479                $search_from_end = - strlen( $file_to_exclude );
480                return 0 !== substr_compare( $tool_path, $file_to_exclude, $search_from_end );
481            }
482        );
483        return $filtered_jetpack_tools;
484    }
485}