PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
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 13.9.2 14.0.1 All 504 releases
← All changes | json-endpoints/jetpack/class.jetpack-json-api-endpoint.php +95 -33 12.7.316.3-a.1 View file →
@@ -1,6 +1,10 @@
1 1 <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 2
3 +if ( ! defined( 'ABSPATH' ) ) {
4 + exit( 0 );
5 +}
6 +
3 7 require JETPACK__PLUGIN_DIR . '/modules/module-info.php';
4 8
5 9 /**
6 10 * Base class for Jetpack Endpoints, has the validate_call helper function.
@@ -7,11 +11,14 @@
7 11 */
8 12 abstract class Jetpack_JSON_API_Endpoint extends WPCOM_JSON_API_Endpoint {
9 13
10 14 /**
11 - * Needed capabilities.
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().
12 19 *
13 - * @var string
20 + * @var string|array|null
14 21 */
15 22 protected $needed_capabilities;
16 23
17 24 /**
@@ -92,13 +99,13 @@
92 99
93 100 /**
94 101 * Switches to the blog and checks current user capabilities.
95 102 *
96 - * @param int $_blog_id - the blog ID.
97 - * @param array $capability - the capabilities of the user.
98 - * @param bool $check_validation - if we're checking the validation.
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.
99 106 *
100 - * @return bool|WP_Error a WP_Error object or true if things are good.
107 + * @return true|WP_Error a WP_Error object or true if things are good.
101 108 */
102 109 protected function validate_call( $_blog_id, $capability, $check_validation = true ) {
103 110 $blog_id = $this->api->switch_to_blog_and_validate_user( $this->api->get_blog_id( $_blog_id ) );
104 111 if ( is_wp_error( $blog_id ) ) {
@@ -132,45 +139,100 @@
132 139
133 140 /**
134 141 * Check capability.
135 142 *
136 - * @param array $capability - the compatability.
143 + * @param string|array|null $capability - the capability declaration to enforce.
137 144 *
138 - * @return bool|WP_Error
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.
139 147 */
140 148 protected function check_capability( $capability ) {
141 - // If this endpoint accepts site based authentication, skip capabilities check.
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.
142 152 if ( $this->accepts_site_based_authentication() ) {
143 153 return true;
144 154 }
145 - if ( is_array( $capability ) ) {
146 - // the idea is that the we can pass in an array of capabilitie that the user needs to have before we allowing them to do something
147 - $capabilities = ( isset( $capability['capabilities'] ) ? $capability['capabilities'] : $capability );
148 155
149 - // We can pass in the number of conditions we must pass by default it is all.
150 - $must_pass = ( isset( $capability['must_pass'] ) && is_int( $capability['must_pass'] ) ? $capability['must_pass'] : count( $capabilities ) );
156 + // Resolve the scalar, list and structured wrapper declaration forms to a single value.
157 + $capabilities = is_array( $capability ) ? ( $capability['capabilities'] ?? $capability ) : $capability;
151 158
152 - $failed = array(); // store the failed capabilities
153 - $passed = 0;
154 - foreach ( $capabilities as $cap ) {
155 - if ( current_user_can( $cap ) ) {
156 - ++$passed;
157 - } else {
158 - $failed[] = $cap;
159 - }
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 );
160 206 }
161 - // Check if all conditions have passed.
162 - if ( $passed < $must_pass ) {
163 - return new WP_Error(
164 - 'unauthorized',
165 - /* translators: %s: comma-separated list of capabilities */
166 - sprintf( __( 'This user is not authorized to %s on this blog.', 'jetpack' ), implode( ', ', $failed ) ),
167 - 403
168 - );
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;
169 225 }
170 - } elseif ( ! current_user_can( $capability ) ) {
171 - // Translators: the capability that the user is not authorized for.
172 - return new WP_Error( 'unauthorized', sprintf( __( 'This user is not authorized to %s on this blog.', 'jetpack' ), $capability ), 403 );
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 + );
173 235 }
174 236
175 237 return true;
176 238 }