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