Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Checkpoint_Endpoint
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 7
462
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
 init
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 route_url
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 register_routes
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
2
 connect
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 email
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 log_out
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * What the checkpoint answers the browser directly.
4 *
5 * @package automattic/jetpack-comments
6 */
7
8namespace Automattic\Jetpack\Comments;
9
10use Automattic\Jetpack\IP\Utils as IP_Utils;
11use WP_Error;
12use WP_REST_Controller;
13use WP_REST_Request;
14use WP_REST_Response;
15use WP_REST_Server;
16
17/**
18 * A fresh signed popup URL, whether an email has an account, and a way to log out.
19 *
20 * The routes are `wpcom/v2`, registered through the WPCOM REST API v2 loader, so
21 * one definition is same-origin on self-hosted and Atomic and served through
22 * `public-api.wordpress.com/wpcom/v2/sites/{id}/…` on Simple. Log out is
23 * admin-ajax instead: only the site's own host can clear its first-party cookie,
24 * and on Simple that host serves no REST API.
25 *
26 * All are open to anyone. Log out carries no nonce because one rendered for a
27 * logged-out reader outlives the page cache; it takes a POST alone, which
28 * SameSite=Lax keeps from carrying the passport across sites, and one from
29 * another site is refused.
30 */
31class Checkpoint_Endpoint extends WP_REST_Controller {
32
33    const CONNECT_ROUTE = 'comments/identity/connect';
34    const EMAIL_ROUTE   = 'comments/identity/email';
35    const LOGOUT_ACTION = 'jetpack_comments_identity_logout';
36
37    /**
38     * The instance registered without the WPCOM loader, which otherwise holds it.
39     *
40     * @var Checkpoint_Endpoint|null
41     */
42    private static $instance = null;
43
44    /**
45     * Whether the routes have been hooked.
46     *
47     * @var bool
48     */
49    private static $hooked = false;
50
51    /**
52     * Wire the routes onto `rest_api_init`. The loader instantiates this once.
53     */
54    public function __construct() {
55        $this->namespace = 'wpcom/v2';
56        $this->rest_base = self::CONNECT_ROUTE;
57
58        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
59    }
60
61    /**
62     * Register the routes and the log-out action. Safe to call more than once.
63     *
64     * @return void
65     */
66    public static function init() {
67        if ( self::$hooked ) {
68            return;
69        }
70
71        self::$hooked = true;
72
73        if ( function_exists( 'wpcom_rest_api_v2_load_plugin' ) ) {
74            wpcom_rest_api_v2_load_plugin( self::class );
75        } else {
76            self::$instance = new self();
77        }
78
79        add_action( 'wp_ajax_nopriv_' . self::LOGOUT_ACTION, array( __CLASS__, 'log_out' ) );
80        add_action( 'wp_ajax_' . self::LOGOUT_ACTION, array( __CLASS__, 'log_out' ) );
81    }
82
83    /**
84     * A route's URL for this host.
85     *
86     * @param string $route The route under `wpcom/v2`.
87     * @return string
88     */
89    public static function route_url( $route ) {
90        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
91            return sprintf( 'https://public-api.wordpress.com/wpcom/v2/sites/%d/%s', Checkpoint::blog_id(), $route );
92        }
93
94        return rest_url( 'wpcom/v2/' . $route );
95    }
96
97    /**
98     * Register the routes.
99     *
100     * @return void
101     */
102    public function register_routes() {
103        register_rest_route(
104            $this->namespace,
105            '/' . self::CONNECT_ROUTE,
106            array(
107                'methods'             => WP_REST_Server::READABLE,
108                'callback'            => array( $this, 'connect' ),
109                'permission_callback' => '__return_true',
110                'args'                => array(
111                    'challenge' => array(
112                        'type'              => 'string',
113                        'required'          => true,
114                        'validate_callback' => array( Checkpoint::class, 'is_challenge' ),
115                    ),
116                ),
117            )
118        );
119
120        register_rest_route(
121            $this->namespace,
122            '/' . self::EMAIL_ROUTE,
123            array(
124                'methods'             => WP_REST_Server::READABLE,
125                'callback'            => array( $this, 'email' ),
126                'permission_callback' => '__return_true',
127                'args'                => array(
128                    'email' => array(
129                        'type'     => 'string',
130                        'format'   => 'email',
131                        'required' => true,
132                    ),
133                ),
134            )
135        );
136    }
137
138    /**
139     * A signed popup URL.
140     *
141     * @param WP_REST_Request $request The request.
142     * @return WP_REST_Response|WP_Error
143     */
144    public function connect( WP_REST_Request $request ) {
145        // On Simple the route is registered ahead of the loader's gates, because
146        // a public-api request runs plugins_loaded on the wrong blog. Gate here.
147        if ( ! Comments::is_enabled() ) {
148            return new WP_Error( 'not_enabled', __( 'Sign-in is not available on this site.', 'jetpack-comments' ), array( 'status' => 404 ) );
149        }
150
151        $connect = Checkpoint::connect_url( $request->get_param( 'challenge' ) );
152
153        if ( is_wp_error( $connect ) ) {
154            return $connect;
155        }
156
157        $response = new WP_REST_Response( $connect );
158        $response->header( 'Cache-Control', 'no-store' );
159
160        return $response;
161    }
162
163    /**
164     * Whether an email belongs to a WordPress.com account, which Simple turns a guest comment away for.
165     *
166     * Only Simple has that rule and only Simple can answer; every other host says no.
167     *
168     * @param WP_REST_Request $request The request.
169     * @return WP_REST_Response|WP_Error
170     */
171    public function email( WP_REST_Request $request ) {
172        if ( ! Comments::is_enabled() ) {
173            return new WP_Error( 'not_enabled', __( 'Sign-in is not available on this site.', 'jetpack-comments' ), array( 'status' => 404 ) );
174        }
175
176        $account = false;
177
178        if ( function_exists( 'is_email_wp_emails' ) ) {
179            $key = 'email_checks_' . md5( (string) IP_Utils::get_ip() );
180
181            // Per address and across every site, so the answer cannot be farmed blog by blog: without a
182            // global group WordPress.com prefixes the key with the blog id. Twenty in ten minutes is
183            // generous for a reader typing, not for anyone sweeping a list.
184            wp_cache_add_global_groups( 'jetpack_comments' );
185            wp_cache_add( $key, 0, 'jetpack_comments', 10 * MINUTE_IN_SECONDS );
186
187            $count = wp_cache_incr( $key, 1, 'jetpack_comments' );
188
189            // With no count to check, say no: the answer is worth less than the limit.
190            if ( false === $count || (int) $count > 20 ) {
191                return new WP_Error( 'rate_limited', __( 'Too many requests. Please wait a moment and try again.', 'jetpack-comments' ), array( 'status' => 429 ) );
192            }
193
194            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; drop once the stubs PR carrying it lands.
195            $account = (bool) is_email_wp_emails( $request->get_param( 'email' ) );
196        }
197
198        $response = new WP_REST_Response( array( 'account' => $account ) );
199        $response->header( 'Cache-Control', 'no-store' );
200
201        return $response;
202    }
203
204    /**
205     * Forget the reader who sent this: their passport and any saved guest details. Does not return.
206     *
207     * @return void
208     */
209    public static function log_out() {
210        nocache_headers();
211
212        // A link from another site is a GET, and a browser without Sec-Fetch-Site would follow it with the passport.
213        if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
214            wp_send_json_error( array( 'code' => 'post_required' ), 405, JSON_UNESCAPED_SLASHES );
215        }
216
217        if ( ! Checkpoint::is_same_site_request() ) {
218            wp_send_json_error( array( 'code' => 'cross_site' ), 403, JSON_UNESCAPED_SLASHES );
219        }
220
221        Passport::revoke();
222
223        foreach ( array( 'comment_author_', 'comment_author_email_', 'comment_author_url_' ) as $cookie ) {
224            setcookie(
225                $cookie . COOKIEHASH,
226                ' ',
227                array(
228                    'expires'  => time() - YEAR_IN_SECONDS,
229                    'path'     => COOKIEPATH,
230                    'domain'   => COOKIE_DOMAIN,
231                    'secure'   => is_ssl(),
232                    'httponly' => true,
233                )
234            );
235        }
236
237        wp_send_json_success( array( 'logged_out' => true ), 200, JSON_UNESCAPED_SLASHES );
238    }
239}