| @@ -160,8 +160,33 @@ | ||
| 160 | 160 | return $result; |
| 161 | 161 | } |
| 162 | 162 | |
| 163 | 163 | /** |
| 164 | + * Values that are shaped like a capability but cannot be one. | |
| 165 | + * | |
| 166 | + * WordPress's `__return_*` helpers are function names. Passed where a | |
| 167 | + * capability belongs they are simply an unknown capability, and an unknown | |
| 168 | + * capability is denied — so they close a route rather than open it. Listed | |
| 169 | + * literally: the check has to be certain, because a capability that merely | |
| 170 | + * happens to share a name with some function is legitimate. | |
| 171 | + */ | |
| 172 | + private const NOT_A_CAPABILITY = array( | |
| 173 | + '__return_true', | |
| 174 | + '__return_false', | |
| 175 | + '__return_zero', | |
| 176 | + '__return_null', | |
| 177 | + '__return_empty_array', | |
| 178 | + '__return_empty_string', | |
| 179 | + ); | |
| 180 | + | |
| 181 | + /** Whether a declared capability is one `current_user_can()` could grant. */ | |
| 182 | + private static function is_capability( $capability ): bool { | |
| 183 | + return is_string( $capability ) | |
| 184 | + && '' !== $capability | |
| 185 | + && ! in_array( $capability, self::NOT_A_CAPABILITY, true ); | |
| 186 | + } | |
| 187 | + | |
| 188 | + /** | |
| 164 | 189 | * Wrap permission_callback with the always-on cap check. A module may |
| 165 | 190 | * declare its own permission_callback for an extra-strict gate; both |
| 166 | 191 | * must pass. |
| 167 | 192 | * |
| @@ -180,8 +205,39 @@ | ||
| 180 | 205 | private static function wrap_permission( Module $module, array $route ): callable { |
| 181 | 206 | $declared = $route['permission_callback'] ?? null; |
| 182 | 207 | $capability = $route['capability'] ?? 'manage_options'; |
| 183 | 208 | $public = ! empty( $route['allow_unauthenticated'] ); |
| 209 | + | |
| 210 | + if ( ! $public && ! self::is_capability( $capability ) ) { | |
| 211 | + /* | |
| 212 | + * A route that reads as public and is closed to everyone. | |
| 213 | + * | |
| 214 | + * `'capability' => '__return_true'` is the shape this catches: a | |
| 215 | + * function name, not a capability. `current_user_can()` denies an | |
| 216 | + * unknown capability — for an anonymous caller AND for a logged-in | |
| 217 | + * administrator — so the route answers 401 to every request while | |
| 218 | + * looking, to the next person who reads it, like it lets everyone | |
| 219 | + * through. One shipped that way, and what found it was a customer's | |
| 220 | + * 401 rather than any test. | |
| 221 | + * | |
| 222 | + * It stays DENIED. Reading "public" out of a value that cannot be a | |
| 223 | + * capability would turn a typo into an authentication bypass, which | |
| 224 | + * is a far worse failure than the one being reported. The fix is to | |
| 225 | + * say `'allow_unauthenticated' => true`, which is the only thing | |
| 226 | + * that opens a route here, and this says so. | |
| 227 | + */ | |
| 228 | + _doing_it_wrong( | |
| 229 | + __METHOD__, | |
| 230 | + esc_html( | |
| 231 | + sprintf( | |
| 232 | + 'Route "%s" declares "%s" as its capability. That is not a capability, so current_user_can() denies every caller, including administrators. Use \'allow_unauthenticated\' => true for a route that is meant to be public.', | |
| 233 | + $module->slug() . ( $route['path'] ?? '' ), | |
| 234 | + is_scalar( $capability ) ? (string) $capability : gettype( $capability ) | |
| 235 | + ) | |
| 236 | + ), | |
| 237 | + 'xspeed 1.2.5' | |
| 238 | + ); | |
| 239 | + } | |
| 184 | 240 | |
| 185 | 241 | return static function ( \WP_REST_Request $request ) use ( $declared, $capability, $public ) { |
| 186 | 242 | if ( ! $public && ! current_user_can( $capability ) ) { |
| 187 | 243 | return false; |