Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.49% covered (warning)
86.49%
96 / 111
76.19% covered (warning)
76.19%
16 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Reprint_Exporter
86.49% covered (warning)
86.49%
96 / 111
76.19% covered (warning)
76.19%
16 / 21
57.42
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
4
 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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 store_secret
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
14.91
 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_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     * 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}