PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.0-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.0-beta
16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / unauth-file-upload.php
jetpack Last commit date
3rd-party 1 week ago _inc 2 days ago css 1 month ago extensions 2 days ago images 2 months ago jetpack_vendor 2 days ago json-endpoints 1 week ago modules 2 days ago sal 1 month ago src 1 month ago vendor 2 days ago views 3 months ago CHANGELOG.md 2 days ago LICENSE.txt 6 months ago SECURITY.md 1 month ago class-jetpack-connection-status.php 2 years ago class-jetpack-gallery-settings.php 7 months ago class-jetpack-newsletter-dashboard-widget.php 7 months ago class-jetpack-pre-connection-jitms.php 2 years ago class-jetpack-stats-dashboard-widget.php 4 months ago class-jetpack-xmlrpc-methods.php 1 month ago class.frame-nonce-preview.php 7 months ago class.jetpack-admin.php 1 week ago class.jetpack-autoupdate.php 7 months ago class.jetpack-cli.php 1 week ago class.jetpack-client-server.php 2 years ago class.jetpack-gutenberg.php 2 days ago class.jetpack-heartbeat.php 1 week ago class.jetpack-modules-list-table.php 7 months ago class.jetpack-network-sites-list-table.php 1 week ago class.jetpack-network.php 1 month ago class.jetpack-plan.php 3 years ago class.jetpack-post-images.php 3 months ago class.jetpack-twitter-cards.php 4 months ago class.jetpack-user-agent.php 1 week ago class.jetpack.php 2 days ago class.json-api-endpoints.php 1 month ago class.json-api.php 2 days ago class.photon.php 3 years ago composer.json 2 days ago enhanced-open-graph.php 1 month ago functions.compat.php 4 months ago functions.cookies.php 2 years ago functions.global.php 1 week ago functions.is-mobile.php 2 years ago functions.opengraph.php 3 months ago functions.photon.php 2 years ago jetpack.php 2 days ago json-api-config.php 3 years ago json-endpoints.php 2 years ago load-jetpack.php 1 week ago locales.php 7 months ago readme.txt 2 days ago unauth-file-upload.php 1 week ago uninstall.php 1 week ago wpml-config.xml 4 years ago
unauth-file-upload.php
391 lines
1 <?php
2 /**
3 * Unauthenticated File Upload Helper Functions.
4 *
5 * @package automattic/jetpack
6 */
7
8 namespace Automattic\Jetpack\UnauthFileUpload;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit( 0 );
12 }
13
14 add_action( 'wp_ajax_jetpack_unauth_file_download', __NAMESPACE__ . '\handle_file_download' );
15 add_filter( 'jetpack_unauth_file_upload_get_file', __NAMESPACE__ . '\get_file_content', 10, 2 );
16 add_filter( 'jetpack_unauth_file_download_url', __NAMESPACE__ . '\filter_get_download_url', 10, 2 );
17
18 /**
19 * The lifetime of a download link, in seconds.
20 *
21 * Kept generous enough that an editor returning to the responses dashboard days later still
22 * has a working link, short enough to limit the exposure of a leaked URL.
23 */
24 const DOWNLOAD_LINK_LIFETIME = 7 * DAY_IN_SECONDS;
25
26 /**
27 * The token scheme version carried in download links.
28 *
29 * Travels in the URL as `token_version` and is bound into the signature, so the signing scheme
30 * can be changed in the future (bump this and branch on the value in authorize_file_download())
31 * without older links being mistaken for the new format.
32 */
33 const DOWNLOAD_TOKEN_VERSION = 'v1';
34
35 /**
36 * Get the secret used to sign download links.
37 *
38 * Defaults to the Jetpack blog token, which this feature already depends on (the file content
39 * is fetched from WP.com as the blog). There is nothing extra to store, and reconnecting
40 * Jetpack rotates the token and so invalidates any outstanding links. Only the secret portion
41 * of the token (after the publicly-visible key prefix) is used, matching the rest of the
42 * connection stack.
43 *
44 * @since 16.0
45 *
46 * @return string The signing key, or an empty string if no secret is available.
47 */
48 function get_download_signing_key() {
49 $blog_token = ( new \Automattic\Jetpack\Connection\Tokens() )->get_access_token();
50 $secret = '';
51
52 if ( $blog_token && ! is_wp_error( $blog_token ) && ! empty( $blog_token->secret ) ) {
53 // Blog tokens are stored as "key.secret"; sign with the secret part only, matching the
54 // rest of the connection stack. Require exactly two non-empty parts so a malformed token
55 // fails closed rather than signing links the fetch path could never honor.
56 $parts = explode( '.', (string) $blog_token->secret, 2 );
57 if ( count( $parts ) === 2 && '' !== $parts[0] && '' !== $parts[1] ) {
58 $secret = $parts[1];
59 }
60 }
61
62 /**
63 * Filters the secret used to sign Forms unauthenticated file-download links.
64 *
65 * Defaults to the Jetpack blog token secret. Environments without a usable blog token
66 * (e.g. WordPress.com Simple) can return their own stable, secret value here so that
67 * download links can still be signed and verified.
68 *
69 * @since 16.0
70 *
71 * @param string $secret The signing secret. Empty string if none is available.
72 */
73 return (string) apply_filters( 'jetpack_unauth_file_download_signing_key', $secret );
74 }
75
76 /**
77 * Generate a signed token authorizing the download of a given file.
78 *
79 * Unlike a WordPress nonce, the token is not tied to a specific user or session, so a link
80 * generated by one logged-in editor works for any logged-in editor. The expiry timestamp is
81 * part of the signed payload, so it cannot be tampered with to extend access.
82 *
83 * @since 16.0
84 *
85 * @param int $file_id The file ID.
86 * @param int $expires Unix timestamp at which the link stops being valid.
87 * @param string $version The token scheme version. Defaults to the current scheme.
88 *
89 * @return string The hex-encoded HMAC-SHA256 token, or an empty string if there is no signing key.
90 */
91 function generate_download_token( $file_id, $expires, $version = DOWNLOAD_TOKEN_VERSION ) {
92 $key = get_download_signing_key();
93
94 if ( '' === $key ) {
95 return '';
96 }
97
98 $payload = 'jetpack_unauth_file_download|' . $version . '|' . (int) $file_id . '|' . (int) $expires;
99 return hash_hmac( 'sha256', $payload, $key );
100 }
101
102 /**
103 * Verify a download token against the expected signature using a constant-time comparison.
104 *
105 * Expiry is checked separately by the caller so it can surface a distinct "expired" message.
106 * Fails closed when no signing key is available (i.e. the site is not connected).
107 *
108 * @since 16.0
109 *
110 * @param int $file_id The file ID.
111 * @param int $expires Unix timestamp the token was signed with.
112 * @param string $token The token supplied in the request.
113 * @param string $version The token scheme version the token was signed with.
114 *
115 * @return bool True if the token matches.
116 */
117 function verify_download_token( $file_id, $expires, $token, $version = DOWNLOAD_TOKEN_VERSION ) {
118 $expected = generate_download_token( $file_id, $expires, $version );
119
120 return '' !== $expected
121 && is_string( $token ) && '' !== $token
122 && hash_equals( $expected, $token );
123 }
124
125 /**
126 * Get the file download URL filter callback.
127 *
128 * @param string $url The file download URL.
129 * @param int $file_id The file ID.
130 *
131 * @return string The file download URL.
132 */
133 function filter_get_download_url( $url, $file_id ) {
134 $expires = time() + DOWNLOAD_LINK_LIFETIME;
135 $token = generate_download_token( $file_id, $expires );
136
137 if ( '' === $token ) {
138 // No signing key (disconnected site, or WP.com Simple without the signing-key filter
139 // wired). Return the passthrough so callers omit the link instead of handing out one
140 // that can never work, and leave a breadcrumb for operators.
141 error_log( 'Jetpack Forms: unable to sign file download link; no signing key available.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
142 return $url;
143 }
144
145 return add_query_arg(
146 array(
147 'action' => 'jetpack_unauth_file_download',
148 'file_id' => $file_id,
149 'expires' => $expires,
150 'token_version' => DOWNLOAD_TOKEN_VERSION,
151 'token' => $token,
152 ),
153 admin_url( 'admin-ajax.php' )
154 );
155 }
156
157 /**
158 * Reject a download request with the generic invalid-link error and a debug code, then exit.
159 *
160 * The message stays generic so it leaks nothing to the requester and stays actionable (a token
161 * can be rejected simply because the blog token rotated on reconnect), while the appended code
162 * identifies which check failed, so a user-reported screenshot is enough to locate the cause:
163 *
164 * 1 - Missing or unrecognized token_version.
165 * 2 - Token signature did not verify.
166 * 3 - Legacy nonce did not verify.
167 * 4 - No token and no legacy nonce supplied.
168 *
169 * @since 16.0
170 *
171 * @param int $code Number identifying the failing check.
172 *
173 * @return void Terminates the request via wp_die().
174 */
175 function invalid_download_link( $code ) {
176 wp_die(
177 esc_html(
178 sprintf(
179 /* translators: %d: error code used for debugging. */
180 __( 'This download link is no longer valid. Reload the responses page to get a fresh link. (%d)', 'jetpack' ),
181 (int) $code
182 )
183 ),
184 '',
185 array( 'response' => 403 )
186 );
187 }
188
189 /**
190 * Authorize a download request and return the requested file ID.
191 *
192 * Holds the security-critical gate (capability check, then either the current signed-token
193 * scheme or the legacy nonce fallback). Terminates the request via wp_die() on any failure and
194 * only returns when the caller is allowed to download the file. Kept separate from the
195 * file-serving logic in handle_file_download() so each branch is unit-testable without the
196 * headers/exit of the serving path.
197 *
198 * @since 16.0
199 *
200 * @return int The validated file ID.
201 */
202 function authorize_file_download() {
203 if ( ! current_user_can( 'edit_pages' ) ) {
204 wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'jetpack' ), '', array( 'response' => 403 ) );
205 }
206
207 $file_id = isset( $_GET['file_id'] ) ? absint( wp_unslash( $_GET['file_id'] ) ) : 0;
208
209 if ( ! $file_id ) {
210 wp_die( esc_html__( 'Invalid file request.', 'jetpack' ), '', array( 'response' => 400 ) );
211 }
212
213 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
214
215 if ( '' !== $token ) {
216 // Current scheme: a site-signed, expiring token. Future schemes can branch on the version.
217 $token_version = isset( $_GET['token_version'] ) ? sanitize_text_field( wp_unslash( $_GET['token_version'] ) ) : '';
218
219 if ( DOWNLOAD_TOKEN_VERSION !== $token_version ) {
220 invalid_download_link( 1 );
221 }
222
223 $expires = isset( $_GET['expires'] ) ? absint( wp_unslash( $_GET['expires'] ) ) : 0;
224
225 // A link is valid up to and including its expiry second; expired once time passes it.
226 if ( ! $expires || $expires < time() ) {
227 wp_die( esc_html__( 'This download link has expired. Reload the responses page to get a fresh link.', 'jetpack' ), '', array( 'response' => 410 ) );
228 }
229
230 // $token_version was asserted equal to DOWNLOAD_TOKEN_VERSION above; pass the constant so
231 // a future multi-version branch can't accidentally feed the request value back in.
232 if ( ! verify_download_token( $file_id, $expires, $token, DOWNLOAD_TOKEN_VERSION ) ) {
233 invalid_download_link( 2 );
234 }
235 } elseif ( isset( $_GET['_wpnonce'] ) ) {
236 /*
237 * Backward compatibility for legacy per-file nonce links that may still be circulating
238 * in already-sent emails. WordPress nonces are only valid for ~24h, so these links
239 * expire on their own shortly after the signed-token scheme ships.
240 *
241 * @todo Remove this legacy nonce fallback in Jetpack 16.3 or later; links signed under
242 * the old scheme self-expire within ~24h of the signed-token scheme shipping.
243 */
244 if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'jetpack_unauth_file_download_nonce_' . $file_id ) ) {
245 invalid_download_link( 3 );
246 }
247 } else {
248 invalid_download_link( 4 );
249 }
250
251 return $file_id;
252 }
253
254 /**
255 * Handle file download requests from the admin page.
256 *
257 * @return never This method never returns as it exits directly
258 */
259 function handle_file_download() {
260 $file_id = authorize_file_download();
261
262 /**
263 * Get the file content that we send to the user to download.
264 *
265 * @since 14.6
266 *
267 * @param array $file_content The file content.
268 * @param string $file_id The file ID.
269 *
270 * @return array|\WP_Error The file array, containing the content, name and type.
271 */
272 $file = apply_filters( 'jetpack_unauth_file_upload_get_file', array(), $file_id );
273
274 if ( is_wp_error( $file ) || empty( $file ) || ! is_array( $file ) ) {
275 wp_die( esc_html__( 'Error retrieving file content.', 'jetpack' ) );
276 }
277
278 // Given $file can be manipulated by a filter, make sure everything is as it should be.
279 $file['content'] = $file['content'] ?? '';
280 $file['type'] = $file['type'] ?? 'application/octet-stream';
281 $file['name'] = $file['name'] ?? '';
282
283 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The request is already authorized in authorize_file_download() before reaching here.
284 $is_preview = isset( $_GET['preview'] ) && 'true' === $_GET['preview'] && is_file_type_previewable( $file['type'] );
285
286 // Clean output buffer
287 if ( ob_get_length() ) {
288 ob_clean();
289 }
290 // Set headers for download
291 header( 'Content-Type: ' . $file['type'] );
292
293 if ( ! $is_preview ) {
294 // Forcing the file to be downloaded is important to prevent XSS attacks.
295 header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $file['name'] ) . '"' );
296 } else {
297 // For preview mode, use inline disposition
298 header( 'Content-Disposition: inline; filename="' . sanitize_file_name( $file['name'] ) . '"' );
299 }
300 header( 'Content-Length: ' . strlen( $file['content'] ) );
301 header( 'Content-Transfer-Encoding: binary' );
302 header( 'Cache-Control: no-cache, must-revalidate, max-age=0' );
303 header( 'Pragma: no-cache' );
304 header( 'Expires: 0' );
305
306 // Output file content and exit
307 echo $file['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary file data
308 exit( 0 );
309 }
310
311 /**
312 * Get the file content.
313 *
314 * @param array $file_content The file content, name and type.
315 * @param integer $file_id The file ID.
316 * @return array|\WP_Error The file content, name and type
317 */
318 function get_file_content( $file_content, $file_id ) {
319 if ( ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple() ) {
320 return $file_content;
321 }
322
323 $blog_id = \Jetpack_Options::get_option( 'id' );
324 $request_url = sprintf( '/sites/%d/unauth-file-upload/%s', $blog_id, $file_id );
325
326 $response = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_blog(
327 $request_url,
328 'v2',
329 array(
330 'method' => 'GET',
331 ),
332 null,
333 'wpcom'
334 );
335
336 $file_content = wp_remote_retrieve_body( $response );
337
338 if ( is_wp_error( $response ) || empty( $file_content ) ) {
339 return new \WP_Error( 'jetpack_unauth_file_upload_error', esc_html__( 'Error retrieving file content.', 'jetpack' ) );
340 }
341
342 try {
343 $content = json_decode( $file_content, true, 3, defined( 'JSON_THROW_ON_ERROR' ) ? \JSON_THROW_ON_ERROR : 0 ); // phpcs:ignore PHPCompatibility.Constants.NewConstants.json_throw_on_errorFound
344 if ( isset( $content['message'] ) ) {
345 return new \WP_Error( 'jetpack_unauth_file_upload_error', esc_html__( 'Error retrieving file content.', 'jetpack' ) );
346 }
347 } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
348 // If the file is not JSON, we assume it's a binary file.
349 }
350
351 $content_disposition = wp_remote_retrieve_header( $response, 'content-disposition' );
352 $filename = '';
353 if ( $content_disposition ) {
354 // Match the filename using a regular expression
355 if ( preg_match( '/filename="([^"]+)"/', $content_disposition, $matches ) ) {
356 $filename = $matches[1]; // Extract the filename
357 }
358 }
359
360 $type = wp_remote_retrieve_header( $response, 'content-type' );
361 if ( empty( $type ) ) {
362 $type = 'application/octet-stream'; // Default to binary if no content type is found
363 }
364
365 return array(
366 'content' => $file_content,
367 'type' => $type,
368 'name' => $filename,
369 );
370 }
371
372 /**
373 * Check which file type is previewable in the browser without downloading them.
374 *
375 * Allow images with extensions jpg, jpeg, png, gif, webp and pdf files.
376 *
377 * @param string $file_type The MIME type of the file.
378 * @return bool True if the file is previable, false otherwise.
379 */
380 function is_file_type_previewable( $file_type ) {
381 $previable_types = array(
382 'image/jpeg',
383 'image/png',
384 'image/gif',
385 'image/webp',
386 'application/pdf',
387 );
388
389 return in_array( $file_type, $previable_types, true );
390 }
391