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