Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.76% covered (warning)
51.76%
88 / 170
21.43% covered (danger)
21.43%
3 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Checkpoint
51.76% covered (warning)
51.76%
88 / 170
21.43% covered (danger)
21.43%
3 / 14
706.27
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 is_available
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 is_challenge
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 connect_url
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 blog_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exchange
72.97% covered (warning)
72.97%
27 / 37
0.00% covered (danger)
0.00%
0 / 1
14.84
 admit
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
10.39
 is_same_site_request
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
7.99
 origin
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 refuse
75.86% covered (warning)
75.86%
22 / 29
0.00% covered (danger)
0.00%
0 / 1
8.90
 attribute
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 akismet_request_args
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 record
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * The checkpoint: where a commenter signed in through WordPress.com is admitted.
4 *
5 * @package automattic/jetpack-comments
6 */
7
8namespace Automattic\Jetpack\Comments;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager;
12use Automattic\Jetpack\Connection\Tokens;
13use Automattic\Jetpack\Status\Host;
14use WP_Error;
15
16/**
17 * Signs the popup URL, redeems the code it hands back, and attributes the comment.
18 *
19 * The WordPress.com half is the Consulate in wpcom's lib/comment-identity.
20 */
21class Checkpoint {
22
23    const CODE_FIELD = 'jetpack_comment_identity_code';
24    // Sent when the form rendered as signed in on the passport. Without it the passport is
25    // left alone, so a log-out that never reached the server still posts as the guest shown.
26    const PASSPORT_FIELD = 'jetpack_comment_identity_passport';
27    // Comment meta: the commenter's id and avatar.
28    const META_ID     = 'jetpack_comment_identity_id';
29    const META_AVATAR = 'jetpack_comment_identity_avatar';
30
31    /**
32     * Singleton instance.
33     *
34     * @var Checkpoint|null
35     */
36    private static $instance = null;
37
38    /**
39     * Whether a signing key exists, memoized per request.
40     *
41     * @var bool|null
42     */
43    private static $available = null;
44
45    /**
46     * The identity admitted for the comment being posted now.
47     *
48     * @var array|null
49     */
50    private $identity = null;
51
52    /**
53     * Register the hooks. Safe to call more than once.
54     *
55     * @return Checkpoint
56     */
57    public static function init() {
58        if ( null === self::$instance ) {
59            self::$instance = new self();
60        }
61
62        return self::$instance;
63    }
64
65    /**
66     * Hook in around core's comment handling.
67     */
68    private function __construct() {
69        add_action( 'pre_comment_on_post', array( $this, 'admit' ), 20 );
70        add_filter( 'preprocess_comment', array( $this, 'attribute' ), 0 );
71        add_action( 'comment_post', array( $this, 'record' ) );
72        add_filter( 'akismet_request_args', array( $this, 'akismet_request_args' ), 10, 2 );
73
74        Checkpoint_Endpoint::init();
75    }
76
77    /**
78     * Whether this site can sign a popup URL.
79     *
80     * @return bool
81     */
82    public static function is_available() {
83        if ( null !== self::$available ) {
84            return self::$available;
85        }
86
87        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
88            self::$available = file_exists( WP_CONTENT_DIR . '/lib/comment-identity/class-consulate.php' );
89        } else {
90            $token           = ( new Tokens() )->get_access_token();
91            self::$available = $token && ! is_wp_error( $token ) && ! empty( $token->secret );
92        }
93
94        return self::$available;
95    }
96
97    /**
98     * Whether a challenge has the shape the popup echoes back.
99     *
100     * @param mixed $challenge The value to check.
101     * @return bool
102     */
103    public static function is_challenge( $challenge ) {
104        return is_string( $challenge ) && 1 === preg_match( '/^[A-Za-z0-9_-]{32,512}\z/', $challenge );
105    }
106
107    /**
108     * A signed popup URL: with the blog token, or on Simple with the Consulate's own key.
109     *
110     * @param string $challenge The challenge to sign.
111     * @return array|WP_Error url, expires, challenge.
112     */
113    public static function connect_url( $challenge ) {
114        if ( ! self::is_challenge( $challenge ) ) {
115            return new WP_Error( 'invalid_request', __( 'Invalid request.', 'jetpack-comments' ), array( 'status' => 400 ) );
116        }
117
118        if ( ! self::is_available() ) {
119            return new WP_Error( 'unavailable', __( 'Sign-in is not available on this site.', 'jetpack-comments' ), array( 'status' => 503 ) );
120        }
121
122        // Scheme, host and port of the page the popup posts back to.
123        $home   = wp_parse_url( home_url() );
124        $origin = ( $home['scheme'] ?? 'https' ) . '://' . ( $home['host'] ?? '' ) . ( empty( $home['port'] ) ? '' : ':' . $home['port'] );
125
126        $params = array(
127            'blog_id'   => self::blog_id(),
128            'challenge' => $challenge,
129            'origin'    => $origin,
130            // Outlasts a slow WordPress.com login or password reset. WordPress.com rejects
131            // an expiry past an hour out, so ten minutes are left for clock skew.
132            'expires'   => time() + 50 * MINUTE_IN_SECONDS,
133        );
134
135        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
136            require_once WP_CONTENT_DIR . '/lib/comment-identity/class-consulate.php';
137
138            // @phan-suppress-next-line PhanUndeclaredClassMethod -- wpcom-only; add to stub-defs.php when the wpcom half lands.
139            $signature = \Automattic\Comment_Identity\Consulate::sign( $params );
140        } else {
141            // Key=value lines sorted by key, exactly as Consulate::signing_payload() on WordPress.com builds them.
142            $signed = $params;
143            ksort( $signed );
144
145            $lines = array();
146            foreach ( $signed as $key => $value ) {
147                $lines[] = $key . '=' . $value;
148            }
149
150            $signature = hash_hmac( 'sha256', implode( "\n", $lines ), ( new Tokens() )->get_access_token()->secret );
151        }
152
153        $query = array_merge( array( 'comment_identity' => 1 ), $params, array( 'signature' => $signature ) );
154
155        return array(
156            'url'       => 'https://public-api.wordpress.com/connect/?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ),
157            'expires'   => $params['expires'],
158            'challenge' => $challenge,
159        );
160    }
161
162    /**
163     * The site's id on WordPress.com.
164     *
165     * @return int
166     */
167    public static function blog_id() {
168        return (int) Manager::get_site_id( true );
169    }
170
171    /**
172     * Redeem a code with WordPress.com.
173     *
174     * @param string $code The code the popup handed back.
175     * @return array|WP_Error site_commenter_id, name, email, avatar, wpcom_user_id.
176     */
177    public static function exchange( $code ) {
178        // The client stops verifying certificates for good on a host whose first request failed to.
179        // An identity must never arrive over a connection anyone on the path could have answered.
180        add_filter( 'jetpack_client_verify_ssl_certs', '__return_true', 999 );
181
182        $response = Client::wpcom_json_api_request_as_blog(
183            sprintf( '/sites/%d/comments/identity/exchange', self::blog_id() ),
184            '2',
185            array(
186                'method'  => 'POST',
187                'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
188                'timeout' => 10,
189            ),
190            (string) wp_json_encode( array( 'code' => (string) $code ), JSON_UNESCAPED_SLASHES ),
191            'wpcom'
192        );
193
194        remove_filter( 'jetpack_client_verify_ssl_certs', '__return_true', 999 );
195
196        $known = array( 'invalid_code', 'blog_mismatch', 'code_used', 'code_expired', 'rate_limited', 'server_error' );
197
198        if ( is_wp_error( $response ) ) {
199            $data = (array) $response->get_error_data();
200            $code = in_array( $response->get_error_code(), $known, true ) ? $response->get_error_code() : 'server_error';
201
202            return new WP_Error( $code, $response->get_error_message(), array( 'status' => (int) ( $data['status'] ?? 500 ) ) );
203        }
204
205        $status = (int) wp_remote_retrieve_response_code( $response );
206        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
207
208        if ( 200 === $status && is_array( $body ) && ! empty( $body['site_commenter_id'] ) ) {
209            return array(
210                'site_commenter_id' => sanitize_text_field( (string) $body['site_commenter_id'] ),
211                'name'              => sanitize_text_field( (string) ( $body['name'] ?? '' ) ),
212                'email'             => sanitize_email( (string) ( $body['email'] ?? '' ) ),
213                'avatar'            => esc_url_raw( (string) ( $body['avatar'] ?? '' ) ),
214                'wpcom_user_id'     => absint( $body['wpcom_user_id'] ?? 0 ),
215            );
216        }
217
218        $error = is_array( $body ) && isset( $body['code'] ) && in_array( $body['code'], $known, true ) ? $body['code'] : 'server_error';
219
220        return new WP_Error(
221            $error,
222            is_array( $body ) && ! empty( $body['message'] ) ? (string) $body['message'] : '',
223            array(
224                'status'      => $status ? $status : 500,
225                'retry_after' => (int) wp_remote_retrieve_header( $response, 'retry-after' ),
226            )
227        );
228    }
229
230    /**
231     * Admit the commenter, from the code they posted or the passport they carry.
232     *
233     * @param int $comment_post_id The post being commented on.
234     * @return void
235     */
236    public function admit( $comment_post_id = 0 ) {
237        if ( ! Comment_Form::enabled_for_post_type( $comment_post_id ) || is_user_logged_in() ) {
238            return;
239        }
240
241        // phpcs:disable WordPress.Security.NonceVerification.Missing -- A logged-out reader posts no nonce; is_same_site_request() guards both fields.
242        $code        = isset( $_POST[ self::CODE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::CODE_FIELD ] ) ) : '';
243        $on_passport = ! empty( $_POST[ self::PASSPORT_FIELD ] );
244        // phpcs:enable WordPress.Security.NonceVerification.Missing
245
246        if ( '' === $code && ! $on_passport ) {
247            return;
248        }
249
250        // A form another site auto-submits can spend a code, landing the passport in whichever browser
251        // sent it, or arrive without the Lax passport cookie and have the refusal below revoke it.
252        if ( ! self::is_same_site_request() ) {
253            self::refuse( new WP_Error( 'invalid_code', '', array( 'status' => 403 ) ) );
254        }
255
256        if ( '' !== $code ) {
257            $identity = self::exchange( $code );
258
259            if ( is_wp_error( $identity ) ) {
260                self::refuse( $identity );
261            }
262
263            Passport::issue( $identity );
264        } else {
265            $identity = Passport::read();
266
267            // The form showed a name the passport no longer backs, say after a
268            // log-out in another tab. Refusing beats publishing as a guest.
269            if ( null === $identity ) {
270                self::refuse( new WP_Error( 'code_expired', '', array( 'status' => 403 ) ) );
271            }
272        }
273
274        $this->identity = $identity;
275
276        // A signed-in commenter counts as registered, and has given a name and email.
277        add_filter( 'pre_option_comment_registration', '__return_zero' );
278        add_filter( 'pre_option_require_name_email', '__return_zero' );
279    }
280
281    /**
282     * Whether the browser says the request came from this site: the Origin or Referer
283     * origin against the home and site origins, or Sec-Fetch-Site alone when a proxy
284     * stripped both. Nothing at all is refused: every browser that runs the sign-in flow sends Origin.
285     *
286     * @return bool
287     */
288    public static function is_same_site_request() {
289        $origins = array();
290
291        // Both: a page on the home host posts to wp-comments-post.php on the site host.
292        foreach ( array( home_url(), site_url() ) as $url ) {
293            $origins[] = self::origin( $url );
294            // A site still set to http:// but served over https posts from the https origin.
295            $origins[] = self::origin( set_url_scheme( $url, 'https' ) );
296        }
297
298        foreach ( array( 'HTTP_ORIGIN', 'HTTP_REFERER' ) as $header ) {
299            // A no-referrer policy posts Origin: null from this site's own pages too, so Sec-Fetch-Site decides.
300            if ( empty( $_SERVER[ $header ] ) || 'null' === $_SERVER[ $header ] ) {
301                continue;
302            }
303
304            $origin = self::origin( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
305
306            return '' !== $origin && in_array( $origin, $origins, true );
307        }
308
309        $site = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) : '';
310
311        return in_array( $site, array( 'same-origin', 'none' ), true );
312    }
313
314    /**
315     * A URL's origin as a browser writes it: scheme, host and any port other than the default.
316     *
317     * @param string $url The URL.
318     * @return string The origin, or '' when the URL has none.
319     */
320    private static function origin( $url ) {
321        $scheme = wp_parse_url( $url, PHP_URL_SCHEME );
322        $host   = wp_parse_url( $url, PHP_URL_HOST );
323        $port   = wp_parse_url( $url, PHP_URL_PORT );
324
325        if ( ! $scheme || ! $host ) {
326            return '';
327        }
328
329        $port = in_array( "$scheme:$port", array( 'http:80', 'https:443' ), true ) ? '' : ( $port ? ":$port" : '' );
330
331        return strtolower( "$scheme://$host$port" );
332    }
333
334    /**
335     * Turn the comment away. Does not return.
336     *
337     * @param WP_Error $error From exchange().
338     * @return void
339     */
340    private static function refuse( WP_Error $error ) {
341        $data   = (array) $error->get_error_data();
342        $status = (int) ( $data['status'] ?? 500 );
343
344        Tracks::record_refusal( $error->get_error_code() );
345
346        switch ( $error->get_error_code() ) {
347            case 'code_expired':
348            case 'code_used':
349                // The sign-in is spent; nothing here can be reused.
350                Passport::revoke();
351                $message = __( 'Your sign-in has expired. Go back and sign in again to leave your comment.', 'jetpack-comments' );
352                $status  = 403;
353                break;
354
355            case 'rate_limited':
356                $message = __( 'Too many sign-in attempts right now. Go back and try again in a moment.', 'jetpack-comments' );
357                if ( ! empty( $data['retry_after'] ) ) {
358                    header( 'Retry-After: ' . (int) $data['retry_after'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- an integer header value.
359                }
360                break;
361
362            case 'invalid_code':
363            case 'blog_mismatch':
364                $message = __( 'Your sign-in could not be verified. Go back and sign in again to leave your comment.', 'jetpack-comments' );
365                break;
366
367            default:
368                $message = __( 'Sign-in is unavailable right now. Go back and try again in a moment.', 'jetpack-comments' );
369                break;
370        }
371
372        wp_die(
373            esc_html( $message ),
374            esc_html__( 'Comment Submission Failure', 'jetpack-comments' ),
375            array(
376                'response'  => absint( $status ),
377                'back_link' => true,
378            )
379        );
380    }
381
382    /**
383     * Attribute the comment to the admitted identity.
384     *
385     * @param array $commentdata Comment data.
386     * @return array
387     */
388    public function attribute( $commentdata ) {
389        if ( null === $this->identity ) {
390            return $commentdata;
391        }
392
393        $commentdata['comment_author']       = $this->identity['name'];
394        $commentdata['comment_author_email'] = $this->identity['email'];
395        $commentdata['comment_author_url']   = '';
396
397        // The WordPress.com account, as Verbum attributed it. Elsewhere that id would name some local user.
398        $user_id = ( new Host() )->is_wpcom_simple() ? absint( $this->identity['wpcom_user_id'] ?? 0 ) : 0;
399        $user_id = $user_id && get_userdata( $user_id ) ? $user_id : 0;
400
401        $commentdata['user_id'] = $user_id;
402        $commentdata['user_ID'] = $user_id;
403
404        return $commentdata;
405    }
406
407    /**
408     * Tell Akismet which WordPress.com account was verified, as Verbum's hc_post_as and hc_userid did.
409     * This form never posts those fields, so any the request carried were forged.
410     *
411     * @param array  $args The request to Akismet.
412     * @param string $path The Akismet endpoint.
413     * @return array
414     */
415    public function akismet_request_args( $args, $path ) {
416        if ( 'comment-check' !== $path || ! is_array( $args ) || ! Comment_Form::enabled_for_post_type( (int) ( $args['comment_post_ID'] ?? 0 ) ) ) {
417            return $args;
418        }
419
420        unset( $args['POST_hc_post_as'], $args['POST_hc_userid'] );
421
422        $user_id = null === $this->identity ? 0 : absint( $this->identity['wpcom_user_id'] ?? 0 );
423
424        if ( $user_id ) {
425            $args['POST_hc_post_as'] = 'wordpress'; // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Verbum's service name.
426            $args['POST_hc_userid']  = (string) $user_id;
427        }
428
429        return $args;
430    }
431
432    /**
433     * Record who left the comment, from the identity admitted on this request rather than $_POST.
434     *
435     * @param int $comment_id The comment ID.
436     * @return void
437     */
438    public function record( $comment_id ) {
439        if ( null === $this->identity ) {
440            return;
441        }
442
443        add_comment_meta( $comment_id, self::META_ID, $this->identity['site_commenter_id'], true );
444
445        if ( '' !== $this->identity['avatar'] ) {
446            add_comment_meta( $comment_id, self::META_AVATAR, $this->identity['avatar'], true );
447        }
448    }
449}