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