Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.16% covered (warning)
62.16%
69 / 111
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Brute_Force_Protection_Math_Authenticate
62.73% covered (warning)
62.73%
69 / 110
33.33% covered (danger)
33.33%
3 / 9
117.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 time_window
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 math_authenticate
43.33% covered (danger)
43.33%
13 / 30
0.00% covered (danger)
0.00%
0 / 1
38.20
 generate_math_page
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 process_generate_math_page
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 carried_login_fields
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 prompt_for_password
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 skip_password_prompt_shake
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 math_form
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
4.11
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
3namespace Automattic\Jetpack\Waf\Brute_Force_Protection;
4
5if ( ! class_exists( 'Brute_Force_Protection_Math_Authenticate' ) ) {
6
7    /**
8     * The math captcha fallback if we can't talk to the Protect API
9     *
10     * @phan-constructor-used-for-side-effects
11     */
12    class Brute_Force_Protection_Math_Authenticate {
13
14        /**
15         * If the class is loaded.
16         *
17         * @var bool
18         */
19        public static $loaded;
20
21        /**
22         * Math page field for the username; core's log field would turn the math POST into a login attempt.
23         */
24        const USER_LOGIN_FIELD = 'jetpack_protect_user_login';
25
26        /**
27         * Class constructor.
28         */
29        public function __construct() {
30
31            if ( self::$loaded ) {
32                return;
33            }
34
35            self::$loaded = 1;
36
37            add_action( 'login_form', array( $this, 'math_form' ) );
38
39            if ( isset( $_POST['jetpack_protect_process_math_form'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- No changes made, just queues the math authenticator hook.
40                add_action( 'init', array( $this, 'process_generate_math_page' ) );
41            }
42        }
43
44        /**
45         * The timeout window.
46         */
47        private static function time_window() {
48            return ceil( time() / ( MINUTE_IN_SECONDS * 2 ) );
49        }
50
51        /**
52         * Verifies that a user answered the math problem correctly while logging in.
53         *
54         * @return bool Returns true if the math is correct. Exits if not.
55         */
56        public static function math_authenticate() {
57            $brute_force_protection = Brute_Force_Protection::instance();
58
59            // A form loaded before the Protect API stopped answering may finish once, but a soft-blocked IP still solves math.
60            if ( 'blocked' !== $brute_force_protection->get_cached_status()
61                && ( new Brute_Force_Protection_Login_Attempt_Token( $brute_force_protection ) )->consume() ) {
62                return true;
63            }
64
65            if ( isset( $_COOKIE['jpp_math_pass'] ) ) {
66                $transient = $brute_force_protection->get_transient( 'jpp_math_pass_' . sanitize_key( $_COOKIE['jpp_math_pass'] ) );
67
68                if ( ! $transient || $transient < 1 ) {
69                    self::generate_math_page();
70                }
71                return true;
72            }
73
74            $ans         = isset( $_POST['jetpack_protect_num'] ) ? (int) $_POST['jetpack_protect_num'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- answers are salted.
75            $correct_ans = isset( $_POST['jetpack_protect_answer'] ) ? sanitize_key( $_POST['jetpack_protect_answer'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
76
77            $time_window  = self::time_window();
78            $salt         = get_site_option( 'jetpack_protect_key' ) . '|' . get_site_option( 'admin_email' ) . '|';
79            $salted_ans_1 = hash_hmac( 'sha1', $ans, $salt . $time_window );
80            $salted_ans_2 = hash_hmac( 'sha1', $ans, $salt . ( $time_window - 1 ) );
81
82            if ( ! $correct_ans || ! $ans ) {
83                self::generate_math_page();
84            } elseif ( ! hash_equals( $salted_ans_1, $correct_ans ) && ! hash_equals( $salted_ans_2, $correct_ans ) ) {
85                wp_die(
86                    wp_kses(
87                        __(
88                            '<strong>You failed to correctly answer the math problem.</strong> This is used to combat spam when Jetpack’s Brute Force Attack Protection API is unavailable. Please use your browser’s back button to return to the login form, press the "refresh" button to generate a new math problem, and try to log in again.',
89                            'jetpack-waf'
90                        ),
91                        array( 'strong' => array() )
92                    ),
93                    '',
94                    array( 'response' => 401 )
95                );
96            } else {
97                return true;
98            }
99        }
100
101        /**
102         * Creates an interim page to collect answers to a math captcha
103         *
104         * @param string $error - the error message.
105         * @return never
106         */
107        public static function generate_math_page( $error = false ) {
108            $carried_fields = self::carried_login_fields();
109            ob_start();
110            ?>
111            <?php if ( '' !== $carried_fields[ self::USER_LOGIN_FIELD ] ) : ?>
112                <h2><?php esc_html_e( 'Please solve this math problem to prove that you are not a bot. Once you solve it, you will need to enter your password again.', 'jetpack-waf' ); ?></h2>
113            <?php else : ?>
114                <h2><?php esc_html_e( 'Please solve this math problem to prove that you are not a bot. Once you solve it, you will need to log in again.', 'jetpack-waf' ); ?></h2>
115            <?php endif ?>
116            <?php if ( $error ) : ?>
117                <h3><?php esc_html_e( 'Your answer was incorrect, please try again.', 'jetpack-waf' ); ?></h3>
118            <?php endif ?>
119
120            <form action="<?php echo esc_url( wp_login_url() ); ?>" method="post" accept-charset="utf-8">
121                <?php self::math_form(); ?>
122                <input type="hidden" name="jetpack_protect_process_math_form" value="1" id="jetpack_protect_process_math_form" />
123                <?php foreach ( array_filter( $carried_fields, 'strlen' ) as $field_name => $field_value ) : ?>
124                    <input type="hidden" name="<?php echo esc_attr( $field_name ); ?>" value="<?php echo esc_attr( $field_value ); ?>" />
125                <?php endforeach ?>
126                <p><input type="submit" value="<?php esc_attr_e( 'Continue &rarr;', 'jetpack-waf' ); ?>"></p>
127            </form>
128            <?php
129            $mathpage = ob_get_contents();
130            ob_end_clean();
131            wp_die(
132                $mathpage, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- content is escaped.
133                '',
134                array( 'response' => 401 )
135            );
136        }
137
138        /**
139         * Generates the math page.
140         */
141        public function process_generate_math_page() {
142            $ans         = isset( $_POST['jetpack_protect_num'] ) ? (int) $_POST['jetpack_protect_num'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- answers are salted.
143            $correct_ans = isset( $_POST['jetpack_protect_answer'] ) ? sanitize_key( $_POST['jetpack_protect_answer'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
144
145            $time_window  = self::time_window();
146            $salt         = get_site_option( 'jetpack_protect_key' ) . '|' . get_site_option( 'admin_email' ) . '|';
147            $salted_ans_1 = hash_hmac( 'sha1', $ans, $salt . $time_window );
148            $salted_ans_2 = hash_hmac( 'sha1', $ans, $salt . ( $time_window - 1 ) );
149
150            if ( ! hash_equals( $salted_ans_1, $correct_ans ) && ! hash_equals( $salted_ans_2, $correct_ans ) ) {
151                self::generate_math_page( true );
152            } else {
153                $temp_pass = substr( hash_hmac( 'sha1', (string) wp_rand( 1, 100000000 ), get_site_option( 'jetpack_protect_key' ) ), 5, 25 );
154
155                $brute_force_protection = Brute_Force_Protection::instance();
156                $brute_force_protection->set_transient( 'jpp_math_pass_' . $temp_pass, 3, DAY_IN_SECONDS );
157                setcookie( 'jpp_math_pass', $temp_pass, time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, false, true );
158                remove_action( 'login_form', array( $this, 'math_form' ) );
159                add_filter( 'wp_login_errors', array( __CLASS__, 'prompt_for_password' ) );
160                add_filter( 'shake_error_codes', array( __CLASS__, 'skip_password_prompt_shake' ) );
161                return true;
162            }
163        }
164
165        /**
166         * Login form fields to send back through the math page, never the password.
167         *
168         * @return string[] Values keyed by field name; empty when not submitted.
169         */
170        private static function carried_login_fields() {
171            // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Echoed back into the login form; sanitized after the type check.
172            $user_login  = $_POST['log'] ?? $_POST[ self::USER_LOGIN_FIELD ] ?? '';
173            $redirect_to = $_REQUEST['redirect_to'] ?? '';
174
175            return array(
176                self::USER_LOGIN_FIELD => is_string( $user_login ) ? sanitize_user( wp_unslash( $user_login ) ) : '',
177                'redirect_to'          => is_string( $redirect_to ) ? esc_url_raw( wp_unslash( $redirect_to ) ) : '',
178                'rememberme'           => empty( $_POST['rememberme'] ) ? '' : 'forever',
179                'interim-login'        => isset( $_REQUEST['interim-login'] ) ? '1' : '',
180            );
181            // phpcs:enable
182        }
183
184        /**
185         * After the standalone math page, ask for the password instead of reporting empty fields.
186         *
187         * @param \WP_Error $errors Login page errors.
188         * @return \WP_Error
189         */
190        public static function prompt_for_password( $errors ) {
191            if ( ! is_wp_error( $errors ) || array( 'empty_username', 'empty_password' ) !== $errors->get_error_codes() ) {
192                return $errors;
193            }
194
195            $user_login = self::carried_login_fields()[ self::USER_LOGIN_FIELD ];
196            if ( '' === $user_login ) {
197                return new \WP_Error( 'empty_password', __( 'Thanks for solving the math problem. Please log in again.', 'jetpack-waf' ), 'message' );
198            }
199
200            // Core prefills the username from log, and only for an empty_password error.
201            $_POST['log'] = wp_slash( $user_login );
202
203            return new \WP_Error( 'empty_password', __( 'Thanks for solving the math problem. Enter your password to log in.', 'jetpack-waf' ), 'message' );
204        }
205
206        /**
207         * Keep the login form from shaking at the post-math password prompt.
208         *
209         * @param string[] $codes Error codes that shake the login form.
210         * @return string[]
211         */
212        public static function skip_password_prompt_shake( $codes ) {
213            return array_values( array_diff( (array) $codes, array( 'empty_password' ) ) );
214        }
215
216        /**
217         * Requires a user to solve a simple equation. Added to any WordPress login form.
218         *
219         * @return VOID outputs html
220         */
221        public static function math_form() {
222            // Check if jpp_math_pass cookie is set and it matches valid transient.
223            if ( isset( $_COOKIE['jpp_math_pass'] ) ) {
224                $brute_force_protection = Brute_Force_Protection::instance();
225                $transient              = $brute_force_protection->get_transient( 'jpp_math_pass_' . sanitize_key( $_COOKIE['jpp_math_pass'] ) );
226
227                if ( $transient && $transient > 0 ) {
228                    return '';
229                }
230            }
231
232            $num1 = wp_rand( 0, 10 );
233            $num2 = wp_rand( 1, 10 );
234            $ans  = $num1 + $num2;
235
236            $time_window = self::time_window();
237            $salt        = get_site_option( 'jetpack_protect_key' ) . '|' . get_site_option( 'admin_email' ) . '|';
238            $salted_ans  = hash_hmac( 'sha1', (string) $ans, $salt . $time_window );
239            ?>
240            <div style="margin: 5px 0 20px;">
241                <p style="font-size: 14px;">
242                    <?php esc_html_e( 'Prove your humanity', 'jetpack-waf' ); ?>
243                </p>
244                <br/>
245                <label for="jetpack_protect_answer" style="vertical-align:super;">
246                    <?php echo esc_html( "$num1 &nbsp; + &nbsp; $num2 &nbsp; = &nbsp;" ); ?>
247                </label>
248                <input type="number" id="jetpack_protect_answer" name="jetpack_protect_num" value="" size="2" style="width:50px;height:25px;vertical-align:middle;font-size:13px;" class="input" />
249                <input type="hidden" name="jetpack_protect_answer" value="<?php echo esc_attr( $salted_ans ); ?>" />
250            </div>
251            <?php
252        }
253    }
254}