PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 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 All 508 releases
← All changes | json-endpoints/jetpack/class.jetpack-json-api-plugins-replace-endpoint.php +270 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,270 @@
1 +<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 +
3 +use Automattic\Jetpack\Automatic_Install_Skin;
4 +
5 +if ( ! defined( 'ABSPATH' ) ) {
6 + exit( 0 );
7 +}
8 +
9 +require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
10 +require_once ABSPATH . 'wp-admin/includes/file.php';
11 +
12 +/**
13 + * Install-or-replace a plugin via zip upload. Passes overwrite_package=true to
14 + * Plugin_Upgrader so an existing plugin at the same slug is replaced in place,
15 + * mirroring wp-admin's "Replace current with uploaded" confirmation flow.
16 + *
17 + * POST /sites/%s/plugins/replace
18 + *
19 + * ## Authentication trust model
20 + *
21 + * Two auth modes are supported:
22 + *
23 + * 1. User auth — the request is mapped to a WordPress user who must hold
24 + * `install_plugins` AND `update_plugins`. The ownership check verifies the
25 + * referenced attachment was authored by that same user, preventing the
26 + * endpoint from being used to touch another user's attachments.
27 + *
28 + * 2. Site-based auth (`allow_jetpack_site_auth => true`) — no user is
29 + * identified; capability and ownership checks are skipped. The wpcom
30 + * upload forwarder is expected to (a) vouch for the caller, (b) create the
31 + * attachment as part of the same request's upload-intercept pipeline, and
32 + * (c) pass the resulting ID through unchanged. If that contract is broken
33 + * on the wpcom side, any trusted site credential becomes install-anything
34 + * on the site.
35 + *
36 + * ## Cross-user attachment-deletion guard
37 + *
38 + * `Jetpack_JSON_API_Plugins_New_Endpoint::validate_call` cleans up the referenced
39 + * attachment on capability-check failure, and now guards that cleanup with an
40 + * ownership check of its own. This Replace endpoint additionally overrides
41 + * `validate_call` to run the ownership check *before* delegating to the parent,
42 + * so an unowned attachment is rejected with `attachment_not_owned` rather than
43 + * the capability error. Both paths leave the legitimate case unchanged: the
44 + * caller's own attachment is cleaned up when their cap check fails.
45 + *
46 + * @phan-constructor-used-for-side-effects
47 + */
48 +class Jetpack_JSON_API_Plugins_Replace_Endpoint extends Jetpack_JSON_API_Plugins_New_Endpoint {
49 + use Jetpack_JSON_API_Attachment_Ownership_Trait;
50 +
51 + /**
52 + * Replace is destructive, so require both install and update caps.
53 + *
54 + * @var array
55 + */
56 + protected $needed_capabilities = array( 'install_plugins', 'update_plugins' );
57 +
58 + /**
59 + * Error codes we are willing to surface to the caller. Anything outside
60 + * this list is collapsed to 'install_failed' with a generic message to
61 + * avoid leaking filesystem paths from Plugin_Upgrader internals.
62 + *
63 + * Kept aligned with codes actually emitted by `WP_Upgrader` /
64 + * `Plugin_Upgrader` — $this->strings[] message keys are NOT error codes
65 + * and do not belong here.
66 + *
67 + * @var array
68 + */
69 + protected static $allowed_error_codes = array(
70 + 'no_package',
71 + 'bad_request',
72 + 'files_not_writable',
73 + 'copy_dir_failed',
74 + 'remove_old_failed',
75 + 'source_read_failed',
76 + 'new_source_read_failed',
77 + 'mkdir_failed_destination',
78 + 'folder_exists',
79 + 'incompatible_archive',
80 + 'incompatible_archive_no_plugins',
81 + 'incompatible_archive_empty',
82 + 'incompatible_php_required_version',
83 + 'incompatible_wp_required_version',
84 + 'unable_to_connect_to_filesystem',
85 + 'fs_unavailable',
86 + 'fs_error',
87 + 'fs_no_plugins_dir',
88 + 'fs_no_folder',
89 + 'fs_no_root_dir',
90 + 'fs_no_content_dir',
91 + 'fs_no_temp_backup_dir',
92 + 'fs_temp_backup_mkdir',
93 + 'fs_temp_backup_move',
94 + );
95 +
96 + /**
97 + * Install, replacing any existing plugin at the same slug.
98 + *
99 + * @return bool|WP_Error
100 + */
101 + public function install() {
102 + $args = $this->input();
103 +
104 + if ( ! isset( $args['zip'][0]['id'] ) || ! is_scalar( $args['zip'][0]['id'] ) ) {
105 + return new WP_Error( 'no_plugin_installed', __( 'No plugin zip file was provided.', 'jetpack' ), 400 );
106 + }
107 +
108 + $expected_slug = isset( $args['slug'] ) && is_scalar( $args['slug'] )
109 + ? strtolower( (string) $args['slug'] )
110 + : '';
111 + if ( ! preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $expected_slug ) ) {
112 + return new WP_Error( 'missing_slug', __( 'A valid plugin slug is required; the replace endpoint refuses to overwrite a plugin whose slug the caller has not declared.', 'jetpack' ), 400 );
113 + }
114 +
115 + $plugin_attachment_id = (int) $args['zip'][0]['id'];
116 +
117 + // Re-checked here so direct invocations of install() (notably the test stubs)
118 + // can't accidentally bypass the ownership guard that validate_call() runs on
119 + // the live request path.
120 + $ownership = $this->validate_attachment_ownership( $plugin_attachment_id );
121 + if ( is_wp_error( $ownership ) ) {
122 + return $ownership;
123 + }
124 +
125 + $zip_check = $this->validate_attachment_is_zip( $plugin_attachment_id );
126 + if ( is_wp_error( $zip_check ) ) {
127 + wp_delete_attachment( $plugin_attachment_id, true );
128 + return $zip_check;
129 + }
130 +
131 + $local_file = get_attached_file( $plugin_attachment_id );
132 + if ( ! $local_file ) {
133 + wp_delete_attachment( $plugin_attachment_id, true );
134 + return new WP_Error( 'local-file-does-not-exist', __( 'Uploaded plugin zip could not be found on disk.', 'jetpack' ), 400 );
135 + }
136 +
137 + $skin = new Automatic_Install_Skin();
138 + $upgrader = new Plugin_Upgrader( $skin );
139 +
140 + $result = $upgrader->install(
141 + $local_file,
142 + array( 'overwrite_package' => true )
143 + );
144 +
145 + wp_delete_attachment( $plugin_attachment_id, true );
146 +
147 + if ( is_wp_error( $result ) ) {
148 + return $this->sanitize_upgrader_error( $result );
149 + }
150 +
151 + if ( ! $result ) {
152 + $error_code = $skin->get_main_error_code();
153 + if ( 'download_failed' === $error_code ) {
154 + $error_code = 'no_package';
155 + }
156 + if ( empty( $error_code ) || ! in_array( $error_code, self::$allowed_error_codes, true ) ) {
157 + $error_code = 'install_failed';
158 + }
159 + return new WP_Error( $error_code, __( 'Plugin installation failed.', 'jetpack' ), 400 );
160 + }
161 +
162 + $plugin_file = $upgrader->plugin_info();
163 + if ( empty( $plugin_file ) ) {
164 + return new WP_Error( 'plugin_replace_info_missing', __( 'Plugin was installed but its identifier could not be determined.', 'jetpack' ), 500 );
165 + }
166 +
167 + // `overwrite_package=true` trusts the zip's own folder name, so a zip whose
168 + // top-level folder differs from the declared slug would clobber an unrelated
169 + // plugin. Verify the post-install identifier matches the caller's contract.
170 + $installed_slug = dirname( $plugin_file );
171 + if ( '.' === $installed_slug || strtolower( $installed_slug ) !== $expected_slug ) {
172 + return new WP_Error( 'slug_mismatch', __( 'The installed plugin does not match the declared slug.', 'jetpack' ), 400 );
173 + }
174 +
175 + $this->plugins = array( $plugin_file );
176 + $this->log[ $plugin_file ] = $upgrader->skin->get_upgrade_messages();
177 +
178 + return true;
179 + }
180 +
181 + /**
182 + * See class docblock — runs the attachment-ownership check before delegating
183 + * to the parent's validate_call(), so an unowned attachment is rejected with
184 + * attachment_not_owned rather than the capability error.
185 + *
186 + * @param int $_blog_id Blog ID.
187 + * @param string $capability Capability.
188 + * @param bool $check_manage_active Whether to check manage-is-active.
189 + * @return bool|WP_Error
190 + */
191 + protected function validate_call( $_blog_id, $capability, $check_manage_active = true ) {
192 + $args = $this->input();
193 + if ( isset( $args['zip'][0]['id'] ) && is_scalar( $args['zip'][0]['id'] ) ) {
194 + $ownership = $this->validate_attachment_ownership( (int) $args['zip'][0]['id'] );
195 + if ( is_wp_error( $ownership ) ) {
196 + return $ownership;
197 + }
198 + }
199 + return parent::validate_call( $_blog_id, $capability, $check_manage_active );
200 + }
201 +
202 + /**
203 + * Collapse unknown error codes and strip potentially path-leaking messages
204 + * from WP_Error instances returned by Plugin_Upgrader.
205 + *
206 + * @param WP_Error $error Raw upgrader error.
207 + * @return WP_Error
208 + */
209 + protected function sanitize_upgrader_error( WP_Error $error ) {
210 + $code = $error->get_error_code();
211 + if ( empty( $code ) || ! in_array( $code, self::$allowed_error_codes, true ) ) {
212 + return new WP_Error( 'install_failed', __( 'Plugin installation failed.', 'jetpack' ), 400 );
213 + }
214 + return new WP_Error( $code, __( 'Plugin installation failed.', 'jetpack' ), 400 );
215 + }
216 +}
217 +
218 +// POST /sites/%s/plugins/replace
219 +new Jetpack_JSON_API_Plugins_Replace_Endpoint(
220 + array(
221 + 'description' => 'Install or replace a plugin on a Jetpack site by uploading a zip file. If a plugin with the same slug is already installed, its destination folder is replaced in place, mirroring wp-admin\'s "Replace current with uploaded" upload flow.',
222 + 'group' => '__do_not_document',
223 + 'stat' => 'plugins:replace',
224 + 'min_version' => '1',
225 + 'max_version' => '1.1',
226 + 'method' => 'POST',
227 + 'path' => '/sites/%s/plugins/replace',
228 + 'path_labels' => array(
229 + '$site' => '(int|string) Site ID or domain',
230 + ),
231 + 'request_format' => array(
232 + 'zip' => '(array) Reference to an uploaded plugin package zip file.',
233 + 'slug' => '(string) The plugin slug the uploaded zip must resolve to. Required; the endpoint rejects zips whose top-level folder does not match.',
234 + ),
235 + 'response_format' => Jetpack_JSON_API_Plugins_Endpoint::$_response_format,
236 + 'allow_jetpack_site_auth' => true,
237 + 'example_request_data' => array(
238 + 'headers' => array(
239 + 'authorization' => 'Bearer YOUR_API_TOKEN',
240 + ),
241 + ),
242 + 'example_request' => 'https://public-api.wordpress.com/rest/v1/sites/example.wordpress.org/plugins/replace',
243 + )
244 +);
245 +
246 +new Jetpack_JSON_API_Plugins_Replace_Endpoint(
247 + array(
248 + 'description' => 'Install or replace a plugin on a Jetpack site by uploading a zip file. If a plugin with the same slug is already installed, its destination folder is replaced in place, mirroring wp-admin\'s "Replace current with uploaded" upload flow.',
249 + 'group' => '__do_not_document',
250 + 'stat' => 'plugins:replace',
251 + 'min_version' => '1.2',
252 + 'method' => 'POST',
253 + 'path' => '/sites/%s/plugins/replace',
254 + 'path_labels' => array(
255 + '$site' => '(int|string) Site ID or domain',
256 + ),
257 + 'request_format' => array(
258 + 'zip' => '(array) Reference to an uploaded plugin package zip file.',
259 + 'slug' => '(string) The plugin slug the uploaded zip must resolve to. Required; the endpoint rejects zips whose top-level folder does not match.',
260 + ),
261 + 'response_format' => Jetpack_JSON_API_Plugins_Endpoint::$_response_format_v1_2,
262 + 'allow_jetpack_site_auth' => true,
263 + 'example_request_data' => array(
264 + 'headers' => array(
265 + 'authorization' => 'Bearer YOUR_API_TOKEN',
266 + ),
267 + ),
268 + 'example_request' => 'https://public-api.wordpress.com/rest/v1.2/sites/example.wordpress.org/plugins/replace',
269 + )
270 +);