Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.69% covered (warning)
87.69%
114 / 130
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Controller
87.69% covered (warning)
87.69%
114 / 130
33.33% covered (danger)
33.33%
2 / 6
18.60
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
1
 export_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enable_export
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
4.16
 rotate_secret
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
3.11
 install_public_key
85.71% covered (warning)
85.71%
30 / 35
0.00% covered (danger)
0.00%
0 / 1
4.05
 permission_check
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
1<?php
2/**
3 * REST controller for Jetpack Reprint export provisioning endpoints.
4 *
5 * Requires a verified Jetpack user token for a site administrator.
6 *
7 * @package automattic/jetpack
8 */
9
10namespace Automattic\Jetpack\Reprint_Export;
11
12use Automattic\Jetpack\Connection\Rest_Authentication;
13use InvalidArgumentException;
14use WordPress\Reprint\Server\PublicKeyServer;
15use WordPress\Reprint\Server\Utils;
16use WP_Error;
17use WP_REST_Controller;
18use WP_REST_Request;
19use WP_REST_Response;
20use WP_REST_Server;
21
22/**
23 * Reprint exporter REST controller.
24 */
25class REST_Controller extends WP_REST_Controller {
26
27    /**
28     * The API namespace.
29     *
30     * @var string
31     */
32    protected $namespace = 'jetpack/v4';
33
34    /**
35     * The REST base path.
36     *
37     * @var string
38     */
39    protected $rest_base = 'reprint';
40
41    /**
42     * Registers the reprint export routes.
43     */
44    public function register_routes() {
45        register_rest_route(
46            $this->namespace,
47            '/' . $this->rest_base . '/rotate-export-secret',
48            array(
49                array(
50                    'methods'             => WP_REST_Server::CREATABLE,
51                    'callback'            => array( $this, 'rotate_secret' ),
52                    'permission_callback' => array( $this, 'permission_check' ),
53                ),
54            )
55        );
56
57        register_rest_route(
58            $this->namespace,
59            '/' . $this->rest_base . '/install-public-key',
60            array(
61                array(
62                    'methods'             => WP_REST_Server::CREATABLE,
63                    'callback'            => array( $this, 'install_public_key' ),
64                    'permission_callback' => array( $this, 'permission_check' ),
65                    'args'                => array(
66                        'public_key' => array(
67                            'description' => __( 'RSA public key, as PEM or one line of base64.', 'jetpack' ),
68                            'type'        => 'string',
69                            'required'    => true,
70                            'maxLength'   => 4096,
71                        ),
72                    ),
73                ),
74            )
75        );
76
77        register_rest_route(
78            $this->namespace,
79            '/' . $this->rest_base . '/enable-export',
80            array(
81                array(
82                    'methods'             => WP_REST_Server::CREATABLE,
83                    'callback'            => array( $this, 'enable_export' ),
84                    'permission_callback' => array( $this, 'permission_check' ),
85                ),
86            )
87        );
88    }
89
90    /**
91     * The export endpoint, so callers need not know the query-arg convention.
92     *
93     * @return string
94     */
95    public function export_url() {
96        return home_url( '/?reprint-api-jetpack' );
97    }
98
99    /**
100     * Opens the export window without changing the credential, so a client can reopen one that closed.
101     *
102     * Registered only where the feature is available, so clients also use a 404
103     * from here to tell whether the site supports export at all.
104     *
105     * @return WP_REST_Response|WP_Error The unix time the window opened at, or a 409 or 500.
106     */
107    public function enable_export() {
108        if ( ! Reprint_Exporter::get_state()['has_credential'] ) {
109            return new WP_Error(
110                'jetpack_reprint_no_credential',
111                Utils::key_auth_required()
112                    ? __( 'Install a public key first.', 'jetpack' )
113                    : __( 'Rotate the export secret first.', 'jetpack' ),
114                array( 'status' => 409 )
115            );
116        }
117
118        $enabled_at = Reprint_Exporter::open_export_window();
119
120        if ( ! Reprint_Exporter::is_export_window_open() ) {
121            return new WP_Error(
122                'jetpack_reprint_window_not_opened',
123                __( 'Failed to open the export window.', 'jetpack' ),
124                array( 'status' => 500 )
125            );
126        }
127
128        Reprint_Exporter::record_event( 'window_opened', array( 'user_id' => get_current_user_id() ) );
129
130        return new WP_REST_Response(
131            array(
132                'enabled_at' => $enabled_at,
133                'export_url' => $this->export_url(),
134            ),
135            200
136        );
137    }
138
139    /**
140     * Rotates the shared secret and returns it.
141     *
142     * Uses random_bytes() rather than wp_generate_password(): sites can filter that
143     * through `random_password`, an extension point a credential should not have.
144     *
145     * @return WP_REST_Response|WP_Error The new secret, or a 500 or 501.
146     */
147    public function rotate_secret() {
148        // Where OpenSSL can verify keys, Reprint refuses secrets, so one issued here would go unused.
149        if ( Utils::key_auth_required() ) {
150            return new WP_Error(
151                'jetpack_reprint_secret_auth_unsupported',
152                __( 'This site verifies exports with public keys. Install a public key instead.', 'jetpack' ),
153                array( 'status' => 501 )
154            );
155        }
156
157        $secret = bin2hex( random_bytes( 32 ) );
158
159        if ( ! Reprint_Exporter::store_secret( $secret ) ) {
160            return new WP_Error(
161                'jetpack_reprint_secret_not_stored',
162                __( 'Failed to persist the new secret.', 'jetpack' ),
163                array( 'status' => 500 )
164            );
165        }
166
167        // A leftover public key would revive if OpenSSL availability changed.
168        delete_option( Reprint_Exporter::PUBLIC_KEY_OPTION );
169
170        Reprint_Exporter::record_event( 'secret_rotated', array( 'user_id' => get_current_user_id() ) );
171
172        return new WP_REST_Response(
173            array(
174                'secret'     => $secret,
175                'export_url' => $this->export_url(),
176            ),
177            200
178        );
179    }
180
181    /**
182     * Replaces the enrolled public key and returns its key id.
183     *
184     * Reprint validates and canonicalizes the key, so the id matches the one the client derives.
185     *
186     * @param WP_REST_Request $request The request, carrying public_key.
187     * @return WP_REST_Response|WP_Error The key id, or a 400, 500 or 501.
188     */
189    public function install_public_key( WP_REST_Request $request ) {
190        if ( ! Utils::key_auth_required() ) {
191            return new WP_Error(
192                'jetpack_reprint_key_auth_unsupported',
193                __( 'This site cannot verify public keys. Rotate the export secret instead.', 'jetpack' ),
194                array( 'status' => 501 )
195            );
196        }
197
198        try {
199            $public_key = PublicKeyServer::assert_valid_public_key( (string) $request['public_key'] );
200        } catch ( InvalidArgumentException $exception ) {
201            return new WP_Error(
202                'jetpack_reprint_invalid_public_key',
203                $exception->getMessage(),
204                array( 'status' => 400 )
205            );
206        }
207
208        if ( ! Reprint_Exporter::store_public_key( $public_key ) ) {
209            return new WP_Error(
210                'jetpack_reprint_public_key_not_stored',
211                __( 'Failed to persist the public key.', 'jetpack' ),
212                array( 'status' => 500 )
213            );
214        }
215
216        delete_option( Reprint_Exporter::SECRET_OPTION );
217
218        $key_id = Utils::public_key_fingerprint( $public_key );
219
220        Reprint_Exporter::record_event(
221            'public_key_installed',
222            array(
223                'user_id' => get_current_user_id(),
224                'key_id'  => $key_id,
225            )
226        );
227
228        return new WP_REST_Response(
229            array(
230                'key_id'     => $key_id,
231                'export_url' => $this->export_url(),
232            ),
233            200
234        );
235    }
236
237    /**
238     * Permission callback: a Jetpack-signed request from a site administrator.
239     *
240     * Deliberately a role check, not a capability one. This installs a credential
241     * that streams the whole database and file tree, and no capability says
242     * that — `manage_options` is the closest, but plugins grant it to shop
243     * managers and the like.
244     *
245     * @return bool
246     */
247    public function permission_check() {
248        if ( ! Rest_Authentication::is_signed_with_user_token() ) {
249            return false;
250        }
251
252        $user = wp_get_current_user();
253        if ( ! $user || ! $user->exists() ) {
254            return false;
255        }
256
257        // Network administrator only: the export takes every table and everything
258        // under ABSPATH, so a subsite administrator would leave with every other
259        // site's users, content and uploads.
260        if ( is_multisite() ) {
261            return is_super_admin( $user->ID );
262        }
263
264        return in_array( 'administrator', $user->roles, true );
265    }
266}