Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.69% |
114 / 130 |
|
33.33% |
2 / 6 |
CRAP | |
0.00% |
0 / 1 |
| REST_Controller | |
87.69% |
114 / 130 |
|
33.33% |
2 / 6 |
18.60 | |
0.00% |
0 / 1 |
| register_routes | |
100.00% |
41 / 41 |
|
100.00% |
1 / 1 |
1 | |||
| export_url | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| enable_export | |
78.26% |
18 / 23 |
|
0.00% |
0 / 1 |
4.16 | |||
| rotate_secret | |
77.27% |
17 / 22 |
|
0.00% |
0 / 1 |
3.11 | |||
| install_public_key | |
85.71% |
30 / 35 |
|
0.00% |
0 / 1 |
4.05 | |||
| permission_check | |
87.50% |
7 / 8 |
|
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 | |
| 10 | namespace Automattic\Jetpack\Reprint_Export; |
| 11 | |
| 12 | use Automattic\Jetpack\Connection\Rest_Authentication; |
| 13 | use InvalidArgumentException; |
| 14 | use WordPress\Reprint\Server\PublicKeyServer; |
| 15 | use WordPress\Reprint\Server\Utils; |
| 16 | use WP_Error; |
| 17 | use WP_REST_Controller; |
| 18 | use WP_REST_Request; |
| 19 | use WP_REST_Response; |
| 20 | use WP_REST_Server; |
| 21 | |
| 22 | /** |
| 23 | * Reprint exporter REST controller. |
| 24 | */ |
| 25 | class 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 | } |