PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-backup / src / rest / class-file-browser-bridge.php

class-file-browser-bridge.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at jetpack_vendor/automattic/jetpack-backup/src/rest/class-file-browser-bridge.php

450 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File browser REST bridge — proxies /sites/{id}/rewind/backup/*.
4 *
5 * @package automattic/jetpack-backup-plugin
6 */
7
8 namespace Automattic\Jetpack\Backup\V0005\REST;
9
10 use Automattic\Jetpack\Connection\Client;
11 use WP_Error;
12 use WP_REST_Request;
13 use WP_REST_Server;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit( 0 );
17 }
18
19 /**
20 * File browser endpoints powering the BackupDetail file tree:
21 * - POST /jetpack/v4/rewind/backup/ls → list folder children
22 * - GET /jetpack/v4/rewind/backup/file-content → text preview proxy
23 * - GET /jetpack/v4/rewind/backup/path-info → per-file metadata
24 *
25 * All three address a file by the *file's own* `period` from `/ls` —
26 * the timestamp at which that file last changed — never by the parent
27 * backup's rewindId. VaultPress records one row per file version and
28 * matches `period` exactly, with no nearest-earlier fallback, so a
29 * file that did not change during the backup the reader is browsing
30 * has no row under the backup's own id.
31 *
32 * Note the two paths take the manifest path in *different* encodings,
33 * because the upstream routes do: `path-info` carries it raw in the
34 * request body, while `file-content` puts standard base64 in a URL
35 * segment that WPCOM `base64_decode()`s. Neither tolerates the other's
36 * form.
37 */
38 class File_Browser_Bridge {
39
40 /**
41 * Cap on text-preview size (bytes). 64 KB is plenty for wp-config.php,
42 * theme style.css, small SQL dumps.
43 */
44 const PREVIEW_MAX_BYTES = 64 * 1024;
45
46 /**
47 * Standard base64, with optional padding — the exact alphabet, and
48 * nothing else.
49 *
50 * Notably excludes `%` and `.`, which is what makes it safe to
51 * interpolate a matching value into a URL path unescaped.
52 */
53 const BASE64_PATTERN = '^[A-Za-z0-9+/]*={0,2}$';
54
55 /**
56 * A snapshot period: Unix seconds, digits only.
57 *
58 * Matches the upstream route's own `(?P<backup_id>\d+)` capture, so
59 * a malformed value fails here with a clear 400 instead of an
60 * opaque upstream 404.
61 */
62 const PERIOD_PATTERN = '^[0-9]+$';
63
64 /**
65 * Register routes.
66 *
67 * @return void
68 */
69 public static function register_routes() {
70 register_rest_route(
71 'jetpack/v4',
72 '/rewind/backup/ls',
73 array(
74 'methods' => WP_REST_Server::CREATABLE,
75 'callback' => array( __CLASS__, 'list_directory' ),
76 'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
77 'args' => array(
78 'rewind_id' => array(
79 'type' => 'string',
80 'required' => true,
81 ),
82 'path' => array(
83 'type' => 'string',
84 'required' => true,
85 ),
86 ),
87 )
88 );
89
90 register_rest_route(
91 'jetpack/v4',
92 '/rewind/backup/path-info',
93 array(
94 'methods' => WP_REST_Server::READABLE,
95 'callback' => array( __CLASS__, 'get_path_info' ),
96 'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
97 'args' => array(
98 'file_period' => array(
99 'type' => 'string',
100 'required' => true,
101 'pattern' => self::PERIOD_PATTERN,
102 ),
103 // Unconstrained on purpose: this one is forwarded in
104 // the request body, not the URL, and a manifest path
105 // can legitimately contain any byte a filename can.
106 'manifest_path' => array(
107 'type' => 'string',
108 'required' => true,
109 ),
110 'extension_type' => array(
111 'type' => 'string',
112 'required' => false,
113 'default' => '',
114 ),
115 ),
116 )
117 );
118
119 register_rest_route(
120 'jetpack/v4',
121 '/rewind/backup/file-content',
122 array(
123 'methods' => WP_REST_Server::READABLE,
124 'callback' => array( __CLASS__, 'get_file_content' ),
125 'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
126 'args' => array(
127 'file_period' => array(
128 'type' => 'string',
129 'required' => true,
130 'pattern' => self::PERIOD_PATTERN,
131 ),
132 // Both of these land in the WPCOM URL *path*, and the
133 // manifest path deliberately goes in unescaped, so
134 // these patterns are the only guard on it. See
135 // `get_file_content()` for why escaping is not an option.
136 'encoded_manifest_path' => array(
137 'type' => 'string',
138 'required' => true,
139 'pattern' => self::BASE64_PATTERN,
140 ),
141 ),
142 )
143 );
144 }
145
146 /**
147 * List folder children. Proxies POST wpcom/v2 /sites/{id}/rewind/backup/ls.
148 *
149 * @param WP_REST_Request $request The REST request.
150 * @return \WP_REST_Response|WP_Error
151 */
152 public static function list_directory( WP_REST_Request $request ) {
153 $blog_id = Rest_Controller::get_blog_id_or_error();
154 if ( is_wp_error( $blog_id ) ) {
155 return $blog_id;
156 }
157
158 $response = Client::wpcom_json_api_request_as_user(
159 sprintf( '/sites/%d/rewind/backup/ls', $blog_id ),
160 'v2',
161 array( 'method' => 'POST' ),
162 array(
163 'backup_id' => $request->get_param( 'rewind_id' ),
164 'path' => $request->get_param( 'path' ),
165 ),
166 'wpcom'
167 );
168
169 return self::forward_response( $response, 'backup_ls_fetch_failed', __( 'Could not list backup contents.', 'jetpack-backup-pkg' ) );
170 }
171
172 /**
173 * Read one file's real metadata. Proxies POST wpcom/v2
174 * /sites/{id}/rewind/backup/path-info.
175 *
176 * Gives the info card the `size`, `hash` and `mtime` VaultPress
177 * actually recorded, rather than the snapshot `period` `/ls` carries.
178 * `manifest_filter` comes back too, which is what a future granular
179 * per-file download needs.
180 *
181 * Two fields are deliberately not surfaced. `download_url` is
182 * hardcoded empty upstream behind a TODO, so it says nothing about
183 * whether the bytes exist. And `data_type` is a small integer type
184 * code — the second character of the manifest path — not a mime
185 * type, so it cannot drive the preview decision; the card keeps
186 * deriving that from the file extension, as Calypso does.
187 *
188 * Upstream answers 200 with an `error` string when the file has no
189 * row, so a caller must branch on `error` rather than on the status.
190 *
191 * @param WP_REST_Request $request The REST request.
192 * @return \WP_REST_Response|WP_Error
193 */
194 public static function get_path_info( WP_REST_Request $request ) {
195 $blog_id = Rest_Controller::get_blog_id_or_error();
196 if ( is_wp_error( $blog_id ) ) {
197 return $blog_id;
198 }
199
200 $response = Client::wpcom_json_api_request_as_user(
201 sprintf( '/sites/%d/rewind/backup/path-info', $blog_id ),
202 'v2',
203 array( 'method' => 'POST' ),
204 array(
205 'backup_id' => $request->get_param( 'file_period' ),
206 'manifest_path' => $request->get_param( 'manifest_path' ),
207 'extension_type' => (string) $request->get_param( 'extension_type' ),
208 ),
209 'wpcom'
210 );
211
212 return self::forward_response( $response, 'backup_path_info_failed', __( 'Could not read file details.', 'jetpack-backup-pkg' ) );
213 }
214
215 /**
216 * Fetch a text file's content for the preview pane.
217 *
218 * Resolves the one-time signed URL from WPCOM, then fetches the
219 * stream server-side and caps the body at PREVIEW_MAX_BYTES.
220 * WPCOM's signed-URL stream endpoint doesn't send CORS headers, so
221 * the browser can't fetch it directly.
222 *
223 * Answers `content`, `is_text` and `truncated`; `content` is null unless
224 * `is_text`. A binary or capped body is not an error — both answer 200.
225 *
226 * VaultPress stores file content per the file's own snapshot
227 * `period` — the timestamp when the file last changed — not by the
228 * parent backup's rewindId. Files don't get re-snapshotted every
229 * backup, so the `{period}` URL segment below is the per-entry
230 * `period` from `/ls`, never the rewindId of the backup the user
231 * is browsing. Sending the rewindId instead silently produces a
232 * signed URL for a non-existent storage location and 400s with
233 * "File not found" at stream time.
234 *
235 * @param WP_REST_Request $request The REST request.
236 * @return \WP_REST_Response|WP_Error
237 */
238 public static function get_file_content( WP_REST_Request $request ) {
239 $blog_id = Rest_Controller::get_blog_id_or_error();
240 if ( is_wp_error( $blog_id ) ) {
241 return $blog_id;
242 }
243 $file_period = (string) $request->get_param( 'file_period' );
244 $encoded_manifest_path = (string) $request->get_param( 'encoded_manifest_path' );
245
246 // Step 1: resolve the signed stream URL.
247 //
248 // `$encoded_manifest_path` goes in verbatim. It is already
249 // base64, and WPCOM's stream route runs a plain
250 // `base64_decode()` on this segment, so percent-encoding it
251 // first is silently destructive: PHP's non-strict decoder
252 // discards the `%` and keeps the `3` and the `D`, both valid
253 // base64 characters. `ZjU6L3dwLWNvbmZpZy5waHA%3D` decodes to
254 // `f5:/wp-config.php7`, and VaultPress then correctly reports
255 // `File not found` for a file that is really there. Base64's
256 // `+`, `/` and `=` are all legal in a path segment, and the
257 // upstream route captures it as `\S+`, so nothing needs
258 // escaping. `$file_period` is digits, so its encoding is a
259 // no-op either way.
260 //
261 // Because nothing escapes it here, `BASE64_PATTERN` on the arg
262 // definition is the guard. That matters: cURL applies RFC 3986
263 // dot-segment removal before the request leaves the host, so an
264 // unconstrained value containing `../` would climb out of this
265 // route — with a `?` swallowing the trailing `/url` — and turn
266 // a file proxy into an arbitrary authenticated WPCOM GET. The
267 // base64 alphabet contains neither `%` nor `.`, so the pattern
268 // closes that off without re-breaking the preview.
269 $url_response = Client::wpcom_json_api_request_as_user(
270 sprintf(
271 '/sites/%d/rewind/backup/%s/file/%s/url',
272 $blog_id,
273 rawurlencode( $file_period ),
274 $encoded_manifest_path
275 ),
276 'v2',
277 array(),
278 null,
279 'wpcom'
280 );
281
282 if ( is_wp_error( $url_response ) ) {
283 return Rest_Controller::transport_error( $url_response, 'backup_file_content_url_failed' );
284 }
285
286 // Cast because `wp_remote_retrieve_response_code()` hands back
287 // whatever the transport put there, and a numeric string fails the
288 // strict comparison below — routing a perfectly good response into
289 // the failure branch, where `upstream_error()`'s clamp then reports
290 // it as a 500. Same reasoning at every bridge; the long version is
291 // on `Rest_Controller::upstream_error()`.
292 $url_status = (int) wp_remote_retrieve_response_code( $url_response );
293 if ( 200 !== $url_status ) {
294 return Rest_Controller::upstream_error(
295 $url_response,
296 'backup_file_content_url_failed',
297 __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' )
298 );
299 }
300
301 $url_body = json_decode( wp_remote_retrieve_body( $url_response ), true );
302 $signed_url = is_array( $url_body ) && isset( $url_body['url'] ) ? $url_body['url'] : null;
303 if ( ! $signed_url || ! wp_http_validate_url( $signed_url ) ) {
304 // Defense-in-depth: WPCOM is supposed to hand back an HTTPS
305 // URL, but a regression that returned `file://…` or another
306 // scheme would otherwise reach `wp_remote_get` below.
307 return new WP_Error(
308 'backup_file_content_url_missing',
309 __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ),
310 array( 'status' => 502 )
311 );
312 }
313
314 // Step 2: fetch the stream body server-side.
315 //
316 // `limit_response_size` caps the body at the HTTP-transport
317 // layer so a multi-GB blob can't be buffered into PHP memory
318 // before truncation. The bridge enforces no mime check at
319 // all — the React layer's allowlist is advisory only — so any
320 // admin can address any blob the WPCOM signer is willing to
321 // hand a URL for.
322 $stream_response = wp_remote_get(
323 $signed_url,
324 array(
325 'timeout' => 15,
326 'limit_response_size' => self::PREVIEW_MAX_BYTES,
327 )
328 );
329
330 if ( is_wp_error( $stream_response ) ) {
331 return Rest_Controller::transport_error( $stream_response, 'backup_file_content_stream_failed' );
332 }
333
334 // Cast, as at the signed-URL lookup above — and this is the call
335 // where it bites hardest, because the response body on the success
336 // side is the previewed file itself. Uncast, a `'200'` from a
337 // transport that reports statuses as strings took the branch below
338 // with the file's own bytes in hand: the preview the reader asked
339 // for was discarded and reported as a 500.
340 $stream_status = (int) wp_remote_retrieve_response_code( $stream_response );
341 if ( 200 !== $stream_status ) {
342 // The one failure here whose reason does not come from the JSON
343 // API. This response is the storage host's, not WordPress.com's,
344 // so two caveats ride along with reusing the shared wrapper.
345 //
346 // Its error bodies are usually XML, which `upstream_reason()`
347 // reads nothing out of; asking anyway costs one `json_decode`
348 // on a path that has already failed, and the host does
349 // sometimes answer in JSON. When it does, the reason is filed
350 // under a key named `wpcom`, which misnames its origin — worth
351 // knowing before anyone reads that field as WordPress.com's.
352 return Rest_Controller::upstream_error(
353 $stream_response,
354 'backup_file_content_stream_failed',
355 __( 'Could not fetch file content.', 'jetpack-backup-pkg' )
356 );
357 }
358
359 $body = wp_remote_retrieve_body( $stream_response );
360
361 // A body at the cap is reported truncated even in the rare case where
362 // the file ends exactly there — the transport cannot tell the two apart.
363 $truncated = strlen( $body ) >= self::PREVIEW_MAX_BYTES;
364 if ( $truncated ) {
365 $body = self::drop_partial_trailing_character( $body );
366 }
367
368 // Bytes that are not text must never go out as `content`: the REST
369 // server's `wp_json_encode()` sanity fallback re-encodes invalid UTF-8
370 // into `?`, so a 200 would hand the reader a corrupted file as if it
371 // were the real one.
372 $is_text = self::is_text( $body );
373
374 return rest_ensure_response(
375 array(
376 'content' => $is_text ? $body : null,
377 'is_text' => $is_text,
378 'truncated' => $truncated,
379 )
380 );
381 }
382
383 /**
384 * Whether a fetched body can be shown in a text preview.
385 *
386 * A NUL byte is valid UTF-8 but does not occur in text, so rejecting it
387 * also catches UTF-16 and the binaries that happen to decode cleanly.
388 *
389 * @param string $body The fetched body.
390 * @return bool
391 */
392 private static function is_text( $body ) {
393 if ( false !== strpos( $body, "\0" ) ) {
394 return false;
395 }
396 // A `//u` pattern fails to match on exactly the bytes `json_encode()`
397 // rejects, and unlike mbstring, PCRE cannot be absent from a host.
398 return 1 === preg_match( '//u', $body );
399 }
400
401 /**
402 * Drop a character the byte cap cut in half.
403 *
404 * The transport counts bytes, so the cut can land inside a multi-byte
405 * sequence and leave a text file looking like binary. A UTF-8 character
406 * is at most four bytes, so at most three can be left over.
407 *
408 * @param string $body The capped body.
409 * @return string
410 */
411 private static function drop_partial_trailing_character( $body ) {
412 for ( $dropped = 0; $dropped < 3 && '' !== $body; $dropped++ ) {
413 if ( self::is_text( $body ) ) {
414 break;
415 }
416 $body = substr( $body, 0, -1 );
417 }
418 return $body;
419 }
420
421 /**
422 * Shared response forwarder for the bridges that just pass through
423 * WPCOM JSON. Wraps transport failures and non-200 responses alike
424 * with bridge-level error codes the front-end branches on, so cURL's
425 * own text never reaches the reader.
426 *
427 * Both wrappers keep what WordPress.com actually said one level down,
428 * under `transport` and `wpcom` respectively, so `$message` names the
429 * operation and the reason survives beside it rather than replacing
430 * it. The client frames the two together when the reason is one only
431 * a sentence can carry.
432 *
433 * @param array|\WP_Error $response The wp_remote_* response.
434 * @param string $code Error code for a transport failure or a non-200.
435 * @param string $message Translated error message for a non-200.
436 * @return \WP_REST_Response|WP_Error
437 */
438 private static function forward_response( $response, $code, $message ) {
439 if ( is_wp_error( $response ) ) {
440 return Rest_Controller::transport_error( $response, $code );
441 }
442 // Cast, as at every other bridge — see `get_file_content()` above.
443 $status_code = (int) wp_remote_retrieve_response_code( $response );
444 if ( 200 !== $status_code ) {
445 return Rest_Controller::upstream_error( $response, $code, $message );
446 }
447 return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) );
448 }
449 }
450