Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.46% covered (warning)
78.46%
51 / 65
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_JSON_API_Endpoint
82.26% covered (warning)
82.26%
51 / 62
25.00% covered (danger)
25.00%
1 / 4
43.24
0.00% covered (danger)
0.00%
0 / 1
 callback
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 result
n/a
0 / 0
n/a
0 / 0
0
 validate_input
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
13.12
 validate_call
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 check_capability
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
18
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
3if ( ! defined( 'ABSPATH' ) ) {
4    exit( 0 );
5}
6
7require JETPACK__PLUGIN_DIR . '/modules/module-info.php';
8
9/**
10 * Base class for Jetpack Endpoints, has the validate_call helper function.
11 */
12abstract class Jetpack_JSON_API_Endpoint extends WPCOM_JSON_API_Endpoint {
13
14    /**
15     * Needed capabilities. Either a single capability, a list of capabilities, or a
16     * `array( 'capabilities' => array( … ), 'must_pass' => int )` wrapper. An empty
17     * value means the endpoint is reachable only with a Jetpack site (blog) token;
18     * every user token is denied. See check_capability().
19     *
20     * @var string|array|null
21     */
22    protected $needed_capabilities;
23
24    /**
25     * Expected actions.
26     *
27     * @var array
28     */
29    protected $expected_actions = array();
30
31    /**
32     * The action.
33     *
34     * @var string
35     */
36    protected $action;
37
38    /**
39     * Callback function.
40     *
41     * @param string $path - the path.
42     * @param int    $blog_id - the blog ID.
43     * @param object $object - parameter is for making the method signature compatible with its parent class method.
44     */
45    public function callback( $path = '', $blog_id = 0, $object = null ) {
46        $error = $this->validate_call( $blog_id, $this->needed_capabilities );
47        if ( is_wp_error( $error ) ) {
48            return $error;
49        }
50
51        $error = $this->validate_input( $object );
52        if ( is_wp_error( $error ) ) {
53            return $error;
54        }
55
56        if ( ! empty( $this->action ) ) {
57            $error = call_user_func( array( $this, $this->action ) );
58            if ( is_wp_error( $error ) ) {
59                return $error;
60            }
61        }
62
63        return $this->result();
64    }
65
66    /**
67     * The result function.
68     */
69    abstract protected function result();
70
71    /**
72     * Validate input.
73     *
74     * @param object $object - unused, for parent class compatability.
75     *
76     * @return bool
77     */
78    protected function validate_input( $object ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
79        $args = $this->input();
80
81        if ( isset( $args['action'] ) && $args['action'] === 'update' ) {
82            $this->action = 'update';
83        }
84
85        if ( preg_match( '!/update/?$!', $this->path ) ) {
86            $this->action = 'update';
87
88        } elseif ( preg_match( '/\/install\/?$/', $this->path ) ) {
89            $this->action = 'install';
90
91        } elseif ( ! empty( $args['action'] ) ) {
92            if ( ! in_array( $args['action'], $this->expected_actions, true ) ) {
93                return new WP_Error( 'invalid_action', __( 'You must specify a valid action', 'jetpack' ) );
94            }
95            $this->action = $args['action'];
96        }
97        return true;
98    }
99
100    /**
101     * Switches to the blog and checks current user capabilities.
102     *
103     * @param int               $_blog_id - the blog ID.
104     * @param string|array|null $capability - the capability declaration to enforce.
105     * @param bool              $check_validation - if we're checking the validation.
106     *
107     * @return true|WP_Error a WP_Error object or true if things are good.
108     */
109    protected function validate_call( $_blog_id, $capability, $check_validation = true ) {
110        $blog_id = $this->api->switch_to_blog_and_validate_user( $this->api->get_blog_id( $_blog_id ) );
111        if ( is_wp_error( $blog_id ) ) {
112            return $blog_id;
113        }
114
115        $error = $this->check_capability( $capability );
116        if ( is_wp_error( $error ) ) {
117            return $error;
118        }
119
120        if (
121            $check_validation &&
122            'GET' !== $this->method &&
123            /**
124             * Filter to disallow JSON API requests to the site.
125             * Setting to false disallows you to manage your site remotely from WordPress.com
126             * and disallows plugin auto-updates.
127             *
128             * @since 7.3.0
129             *
130             * @param bool $check_validation Whether to allow API requests to manage the site
131             */
132            ! apply_filters( 'jetpack_json_manage_api_enabled', $check_validation )
133        ) {
134            return new WP_Error( 'unauthorized_full_access', __( 'Full management mode is off for this site.', 'jetpack' ), 403 );
135        }
136
137        return true;
138    }
139
140    /**
141     * Check capability.
142     *
143     * @param string|array|null $capability - the capability declaration to enforce.
144     *
145     * @return true|WP_Error True when authorized; a WP_Error otherwise. Never return a bare falsey
146     *                       value here: validate_call() authorizes on anything that is not a WP_Error.
147     */
148    protected function check_capability( $capability ) {
149        // If this endpoint accepts site based authentication, skip capabilities check. Note that
150        // accepts_site_based_authentication() infers site authentication from a zero current user id;
151        // it does not verify that a blog token signed the request.
152        if ( $this->accepts_site_based_authentication() ) {
153            return true;
154        }
155
156        // Resolve the scalar, list and structured wrapper declaration forms to a single value.
157        $capabilities = is_array( $capability ) ? ( $capability['capabilities'] ?? $capability ) : $capability;
158
159        // Deny by default: a declaration that names no capability must never authorize a user token.
160        // Endpoints that declare none (e.g. the Backup helper-script endpoints) are reachable only with
161        // a Jetpack site (blog) token, and the short-circuit above -- enabled by `allow_jetpack_site_auth`,
162        // not `allow_fallback_to_jetpack_blog_token` -- is the only way past this deny. Without the guard
163        // an empty set makes `$must_pass` 0 below, so any connected user token would pass. The check runs
164        // on the resolved set, so the scalar forms meaning "nothing required" are covered by the same rule.
165        //
166        // Scope: under `IS_WPCOM` this file does not run at all -- see the endpoint directory swap in
167        // json-endpoints.php -- so this guard governs self-hosted and Atomic sites only.
168        if ( empty( $capabilities ) ) {
169            return new WP_Error( 'unauthorized_site_token_required', __( 'This endpoint is only accessible using a Jetpack site token.', 'jetpack' ), 403 );
170        }
171
172        // Normalize the scalar and list declaration forms to a single list, so that the validation and
173        // the capability loop below apply identically to both. A one-element list produces the same
174        // error message the scalar branch used to build directly.
175        $required_capabilities = is_array( $capabilities ) ? array_values( $capabilities ) : array( $capabilities );
176
177        // An entry nested inside a non-empty list survives the emptiness check above, and core treats two
178        // classes of malformed name as a grant rather than a refusal: `WP_User::has_cap()` routes anything
179        // `is_numeric()` through its legacy user-level shim, where `0` and `'0'` become `level_0` that every
180        // default role holds, and a name core cannot map to `do_not_allow` is granted outright to a network
181        // super admin. Either way `array( 0 )` or `array( ' ' )` reproduces the empty set's fail-open.
182        //
183        // Entries are therefore compared against their canonical form rather than repaired into it: padding
184        // is a malformed declaration, not something to strip and accept. The character class covers what
185        // PHP's byte-wise `trim()` leaves behind (form feed, NBSP, the other Unicode separators, the BOM)
186        // and also whatever PCRE's table still considers unassigned, so a boundary character newer than
187        // that table is denied rather than admitted, which is the safer direction for a guard that cannot
188        // classify it. Comparing before `is_numeric()` keeps that test stable across the supported PHP
189        // matrix, where `is_numeric( '0 ' )` is false before 8.0 and true from 8.0 on. `$canonical` is null
190        // either because `preg_replace()` cannot process invalid UTF-8 or because a non-string never
191        // reached it, and the null test is load-bearing for the second: `null !== null` is false, and
192        // `is_numeric( null )` is false too.
193        //
194        // The check is anchored, so a name mangled in its interior still reaches `current_user_can()` by
195        // the unmappable path above. No declaration is request-derived, so that residual is a hardening
196        // gap. A wrapper with no `capabilities` key lands here too, but only when its metadata is not
197        // capability-shaped: `array( 'must_pass' => 0 )` is rejected, while `array( 'must_pass' => 'read' )`
198        // is indistinguishable from the list `array( 'read' )` and gets an ordinary capability check.
199        foreach ( $required_capabilities as $required_capability ) {
200            $canonical = is_string( $required_capability )
201                ? preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $required_capability )
202                : null;
203
204            if ( null === $canonical || '' === $canonical || $canonical !== $required_capability || is_numeric( $required_capability ) ) {
205                return new WP_Error( 'unauthorized_capability_declaration', __( 'This endpoint does not declare a valid capability requirement.', 'jetpack' ), 403 );
206            }
207        }
208
209        // We can pass in the number of conditions we must pass by default it is all.
210        $must_pass = ( is_array( $capability ) && isset( $capability['must_pass'] ) && is_int( $capability['must_pass'] ) ? $capability['must_pass'] : count( $required_capabilities ) );
211
212        // A threshold below 1 authorizes unconditionally no matter what the capability list holds,
213        // which is the same fail-open the empty set produced. `is_int()` above admits negatives too.
214        if ( $must_pass < 1 ) {
215            return new WP_Error( 'unauthorized_capability_threshold', __( 'This endpoint requires at least one capability check to pass.', 'jetpack' ), 403 );
216        }
217
218        $failed = array(); // store the failed capabilities
219        $passed = 0;
220        foreach ( $required_capabilities as $required_capability ) {
221            if ( current_user_can( $required_capability ) ) {
222                ++$passed;
223            } else {
224                $failed[] = $required_capability;
225            }
226        }
227        // Check if all conditions have passed.
228        if ( $passed < $must_pass ) {
229            return new WP_Error(
230                'unauthorized',
231                /* translators: %s: comma-separated list of capabilities */
232                sprintf( __( 'This user is not authorized to %s on this blog.', 'jetpack' ), implode( ', ', $failed ) ),
233                403
234            );
235        }
236
237        return true;
238    }
239}