PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 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 All 508 releases
← All changes | src/reprint-export/class-reprint-exporter.php +443 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,443 @@
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 +}