Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.36% covered (danger)
26.36%
29 / 110
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_JSON_API_Plugins_Replace_Endpoint
52.73% covered (warning)
52.73%
29 / 55
33.33% covered (danger)
33.33%
1 / 3
84.85
0.00% covered (danger)
0.00%
0 / 1
 install
44.44% covered (danger)
44.44%
20 / 45
0.00% covered (danger)
0.00%
0 / 1
66.55
 validate_call
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 sanitize_upgrader_error
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
3use Automattic\Jetpack\Automatic_Install_Skin;
4
5if ( ! defined( 'ABSPATH' ) ) {
6    exit( 0 );
7}
8
9require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
10require_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` deletes the referenced
39 * attachment on capability-check failure without verifying the caller owns it.
40 * This Replace endpoint overrides `validate_call` to run the ownership check
41 * first, so a low-privilege caller cannot use it to hard-delete another user's
42 * attachment as a side-effect of the cap check failing. The parent's behavior
43 * is unchanged for the legitimate case (caller's own attachment is cleaned up
44 * when their cap check fails).
45 *
46 * @phan-constructor-used-for-side-effects
47 */
48class 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(), which deletes the referenced attachment
184     * on capability-check failure without verifying ownership.
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
219new 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
246new 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);