PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-comments / src / identity / checkpoint / class-checkpoint-endpoint.php

class-checkpoint-endpoint.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-comments/src/identity/checkpoint/class-checkpoint-endpoint.php

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