← All changes
|
json-endpoints/jetpack/class.jetpack-json-api-endpoint.php
+95
-33
12.7.3
→
16.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 | } |