PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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 13.8.3 All 506 releases
jetpack / src / reprint-export / class-reprint-exporter.php

class-reprint-exporter.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at src/reprint-export/class-reprint-exporter.php

444 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * HMAC-authenticated, time-limited Reprint export for Pressable and Atomic
4 * sites.
5 *
6 * @package automattic/jetpack
7 */
8
9 namespace Automattic\Jetpack\Reprint_Export;
10
11 use Automattic\Jetpack\Constants;
12 use Automattic\Jetpack\Status\Host;
13
14 /**
15 * Reprint exporter for Jetpack (Pressable and WordPress.com/Atomic).
16 */
17 class Reprint_Exporter {
18
19 /**
20 * Jetpack-specific option holding the per-site HMAC shared secret.
21 *
22 * @var string
23 */
24 const SECRET_OPTION = 'jetpack_reprint_exporter_secret';
25
26 /**
27 * Jetpack-specific option holding the unix timestamp of the last time the
28 * export window was opened. The window is a sliding 60-minute one.
29 *
30 * @var string
31 */
32 const ENABLED_OPTION = 'jetpack_reprint_exporter_enabled';
33
34 /**
35 * Clock-skew tolerance, in seconds, allowed for HMAC signatures.
36 *
37 * @var int
38 */
39 const HMAC_CLOCK_SKEW = 300;
40
41 /**
42 * Whether the exporter is in the middle of one of its own option writes.
43 *
44 * @var bool
45 */
46 private static $writing_own_options = false;
47
48 /**
49 * Initializes Reprint export where it is available.
50 */
51 public static function maybe_init() {
52 self::protect_options();
53
54 if ( self::is_available() ) {
55 self::init();
56 }
57 }
58
59 /**
60 * Blocks writes to the two export options from anywhere but this class.
61 *
62 * Whoever sets both can export the whole site, since they pick the secret
63 * and can then sign their own requests. Allowed by where the write came
64 * from, not by who is logged in: the usual arbitrary-option-write bug is a
65 * form missing its nonce, running in an administrator's own session.
66 *
67 * This only guards writes made after it runs, at after_setup_theme, and
68 * module loading skips it entirely while Jetpack is inactive or
69 * disconnected. discard_credentials() clears anything left from those last
70 * two, but nothing catches a write made earlier in a normal request.
71 */
72 public static function protect_options() {
73 foreach ( array( self::SECRET_OPTION, self::ENABLED_OPTION ) as $option ) {
74 // Last word: a later filter must not be able to reinstate the value.
75 add_filter( "pre_update_option_{$option}", array( __CLASS__, 'veto_foreign_update' ), PHP_INT_MAX, 2 );
76 }
77
78 // add_option() has no filter that can cancel a write, only actions either
79 // side of the insert, so stopping the request is the only lever.
80 add_action( 'add_option', array( __CLASS__, 'veto_foreign_add' ), 10, 1 );
81 }
82
83 /**
84 * Cancels a foreign update by handing back the value already stored.
85 *
86 * @param mixed $value The incoming value.
87 * @param mixed $old_value The value currently stored.
88 * @return mixed The incoming value for our own writes, the stored one otherwise.
89 */
90 public static function veto_foreign_update( $value, $old_value ) {
91 return self::is_own_option_write() ? $value : $old_value;
92 }
93
94 /**
95 * Stops the request when something else tries to create either option.
96 *
97 * @param string $option The option being added.
98 */
99 public static function veto_foreign_add( $option ) {
100 if ( self::SECRET_OPTION !== $option && self::ENABLED_OPTION !== $option ) {
101 return;
102 }
103
104 if ( self::is_own_option_write() ) {
105 return;
106 }
107
108 wp_die(
109 esc_html__( 'Reprint export options can only be written by Jetpack itself.', 'jetpack' ),
110 esc_html__( 'Forbidden', 'jetpack' ),
111 array( 'response' => 403 )
112 );
113 }
114
115 /**
116 * Whether this write is made by the exporter.
117 *
118 * @return bool
119 */
120 private static function is_own_option_write() {
121 return self::$writing_own_options;
122 }
123
124 /**
125 * Writes one of the export options with the guard held open.
126 *
127 * @param string $option Option name.
128 * @param mixed $value Value to store.
129 * @return bool Whether the value was changed.
130 */
131 private static function write_option( $option, $value ) {
132 self::$writing_own_options = true;
133 try {
134 return update_option( $option, $value, false );
135 } finally {
136 self::$writing_own_options = false;
137 }
138 }
139
140 /**
141 * Reports an export event.
142 *
143 * @param string $event Event name.
144 * @param array $context Details of the event.
145 */
146 public static function record_event( $event, array $context = array() ) {
147 /**
148 * Fires when a Reprint export request ends in an export or an error.
149 *
150 * A request the handler ignores fires nothing, and no event carries the
151 * secret or the signature. An export with no secret_rotated or
152 * window_opened event before it used a secret this site did not create.
153 *
154 * @since 16.2
155 *
156 * @param string $event Event name.
157 * @param array $context Details of the event.
158 */
159 do_action( 'jetpack_reprint_export_event', $event, $context );
160 }
161
162 /**
163 * Discards any stored export credentials.
164 *
165 * Clears whatever was written while protect_options() was not in place. Runs
166 * at plugin activation and when the site connects to or disconnects from
167 * WordPress.com. It does not catch a write made before after_setup_theme
168 * on a site that stays connected.
169 */
170 public static function discard_credentials() {
171 $had_secret = delete_option( self::SECRET_OPTION );
172 $had_window = delete_option( self::ENABLED_OPTION );
173
174 if ( $had_secret || $had_window ) {
175 // current_filter() rather than a parameter: jetpack_site_registered
176 // passes a blog ID to its callbacks, which would land in one.
177 self::record_event(
178 'credentials_discarded',
179 array( 'boundary' => current_filter() )
180 );
181 }
182 }
183
184 /**
185 * Stores a newly created shared secret.
186 *
187 * @param string $secret The new secret.
188 * @return bool Whether the secret was stored.
189 */
190 public static function store_secret( $secret ) {
191 return self::write_option( self::SECRET_OPTION, $secret );
192 }
193
194 /**
195 * Registers the WordPress hooks. Only ever called on sites where
196 * is_available() is true (see maybe_init()).
197 */
198 public static function init() {
199 add_action( 'parse_request', array( new self(), 'handle_request' ), 0 );
200 add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
201 }
202
203 /**
204 * Whether Reprint export support is available on the current site.
205 *
206 * Pressable and WordPress.com (Atomic) only. The filter can switch it off
207 * there; it cannot switch it on anywhere else.
208 *
209 * @return bool
210 */
211 public static function is_available() {
212 if ( ! ( Constants::is_true( 'IS_PRESSABLE' ) || ( new Host() )->is_woa_site() ) ) {
213 return false;
214 }
215
216 /**
217 * Filters whether Jetpack Reprint export support is available on the
218 * current site.
219 *
220 * @since 16.2
221 *
222 * @param bool $available Whether Reprint export support is available.
223 */
224 return (bool) apply_filters( 'jetpack_reprint_export_available', true );
225 }
226
227 /**
228 * Registers Reprint REST routes.
229 */
230 public static function register_rest_routes() {
231 ( new REST_Controller() )->register_routes();
232 }
233
234 /**
235 * Handles the ?reprint-api-jetpack request.
236 *
237 * Runs before template redirects so export requests also work on private
238 * sites.
239 *
240 * @param \WP $wp The WordPress environment instance.
241 */
242 public function handle_request( $wp ) {
243 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
244 if ( ! isset( $_GET['reprint-api-jetpack'] ) ) {
245 return;
246 }
247
248 // Recheck availability so a filter can disable an already registered handler.
249 if ( ! self::is_available() ) {
250 return;
251 }
252
253 // Do not let the query var claim non-root WordPress routes.
254 if ( '' !== $wp->request ) {
255 return;
256 }
257
258 // Any origin: the client may run in a browser (Playground) from
259 // deployments we cannot know ahead of time, and origin is no boundary
260 // when every request needs the HMAC secret anyway. Preflights come
261 // before HMAC because browsers send them without credentials, and
262 // before the window check so a client whose window has closed can reach
263 // the 409 below.
264 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
265 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : '';
266 if ( 'OPTIONS' === $request_method ) {
267 $this->send_cors_headers();
268 if ( ! headers_sent() ) {
269 header( 'Allow: GET, POST, OPTIONS' );
270 }
271 $this->terminate();
272 return;
273 }
274
275 // Without a valid signature a closed window answers nothing, so an idle
276 // site stays indistinguishable from one that never had the feature.
277 $window_open = self::is_export_window_open();
278
279 $secret = get_option( self::SECRET_OPTION, '' );
280 if ( ! is_string( $secret ) || '' === $secret ) {
281 if ( ! $window_open ) {
282 return;
283 }
284 $this->error( 503, 'Export not configured. Please rotate the shared secret via POST /jetpack/v4/reprint/rotate-export-secret.' );
285 return;
286 }
287
288 $auth_error = $this->verify_hmac( $secret );
289 if ( null !== $auth_error ) {
290 if ( ! $window_open ) {
291 return;
292 }
293 $this->error( 403, $auth_error );
294 return;
295 }
296
297 // Signature checks out, so say which state this is: still here, only
298 // needing re-arming, rather than gone.
299 if ( ! $window_open ) {
300 $this->error( 409, 'Export window closed. Re-open it via POST /jetpack/v4/reprint/enable-export.' );
301 return;
302 }
303
304 // An export spans many requests and can run past the hour, so keep the
305 // window open while a client is working.
306 self::open_export_window();
307
308 try {
309 $this->serve_export();
310 } catch ( \InvalidArgumentException $exception ) {
311 $this->error( 400, $exception->getMessage() );
312 return;
313 }
314
315 self::record_event( 'export_served', array( 'endpoint' => $this->requested_endpoint() ) );
316 $this->terminate();
317 }
318
319 /**
320 * The endpoint the client asked for, or 'unknown'.
321 *
322 * Matched against the set the export server accepts so an unexpected value
323 * cannot travel into a consumer's log.
324 *
325 * @return string
326 */
327 protected function requested_endpoint() {
328 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
329 $endpoint = isset( $_GET['endpoint'] ) ? sanitize_key( wp_unslash( $_GET['endpoint'] ) ) : '';
330
331 $known = array( 'preflight', 'db_index', 'sql_chunk', 'file_index', 'file_fetch' );
332
333 return in_array( $endpoint, $known, true ) ? $endpoint : 'unknown';
334 }
335
336 /**
337 * Whether the current export window is open.
338 *
339 * @param int|null $now Unix time to compare against, or null for the
340 * current time. Tests pass a fixed time.
341 * @return bool
342 */
343 public static function is_export_window_open( $now = null ) {
344 $enabled_at = (int) get_option( self::ENABLED_OPTION, 0 );
345 $now = null === $now ? time() : (int) $now;
346 return $enabled_at > 0
347 && $enabled_at <= $now + self::HMAC_CLOCK_SKEW
348 && ( $now - $enabled_at ) <= HOUR_IN_SECONDS;
349 }
350
351 /**
352 * Opens the export window by stamping the enabled option with the current
353 * time.
354 *
355 * @return int The unix timestamp the window was opened at.
356 */
357 public static function open_export_window() {
358 $now = time();
359 self::write_option( self::ENABLED_OPTION, $now );
360 return $now;
361 }
362
363 /**
364 * Verifies the HMAC signature of the current request.
365 *
366 * Seam for tests to override without instantiating the real server.
367 *
368 * @param string $secret The per-site shared secret.
369 * @return string|null Error message on failure, null on success.
370 */
371 protected function verify_hmac( $secret ) {
372 $hmac_server = new \Site_Export_HMAC_Server( $secret, self::HMAC_CLOCK_SKEW );
373 return $hmac_server->verify_globals();
374 }
375
376 /**
377 * Streams the export response.
378 *
379 * Seam for tests to override so they don't perform a real export.
380 */
381 protected function serve_export() {
382 $this->send_cors_headers();
383 \Site_Export_HTTP_Server::serve( array( 'default_directory' => ABSPATH ) );
384 }
385
386 /**
387 * Emits the CORS headers the export client needs.
388 *
389 * Sent only with responses we actually produce, so a request that falls
390 * through to WordPress does not pick them up. See handle_request() for why
391 * any origin is allowed.
392 */
393 protected function send_cors_headers() {
394 if ( headers_sent() ) {
395 return;
396 }
397
398 header( 'Access-Control-Allow-Origin: *' );
399 header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
400 header( 'Access-Control-Allow-Headers: *' );
401 }
402
403 /**
404 * Sends a JSON error response and terminates.
405 *
406 * @param int $code HTTP status code.
407 * @param string $message Error description.
408 */
409 protected function error( $code, $message ) {
410 self::record_event(
411 'export_refused',
412 array(
413 'code' => $code,
414 'reason' => $message,
415 )
416 );
417
418 $this->send_cors_headers();
419 if ( ! headers_sent() ) {
420 http_response_code( $code );
421 header( 'Content-Type: application/json' );
422 }
423 // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
424 echo json_encode(
425 array(
426 'error' => $message,
427 'code' => $code,
428 ),
429 JSON_FORCE_OBJECT
430 );
431 $this->terminate();
432 }
433
434 /**
435 * Terminates the request.
436 *
437 * Seam wrapping exit() so a test double can record that the request ended
438 * and still assert what happened on the way out.
439 */
440 protected function terminate() {
441 exit;
442 }
443 }
444