| @@ -19,8 +19,22 @@ | ||
| 19 | 19 | */ |
| 20 | 20 | class MainWP_Abilities { |
| 21 | 21 | |
| 22 | 22 | /** |
| 23 | + * Maximum encoded GET input size. | |
| 24 | + * | |
| 25 | + * @var int | |
| 26 | + */ | |
| 27 | + private const MAX_GET_INPUT_BYTES = 8192; | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * Maximum DELETE JSON body size. | |
| 31 | + * | |
| 32 | + * @var int | |
| 33 | + */ | |
| 34 | + private const MAX_DELETE_INPUT_BYTES = 1048576; | |
| 35 | + | |
| 36 | + /** | |
| 23 | 37 | * Initialize the Abilities integration. |
| 24 | 38 | * |
| 25 | 39 | * Cron handlers are always initialized to process any previously queued jobs, |
| 26 | 40 | * even if the Abilities API is later disabled. The cron handler constructor |
| @@ -39,8 +53,17 @@ | ||
| 39 | 53 | if ( ! function_exists( 'wp_register_ability' ) ) { |
| 40 | 54 | return; |
| 41 | 55 | } |
| 42 | 56 | |
| 57 | + if ( false === has_filter( 'rest_request_before_callbacks', array( static::class, 'normalize_rest_input_transport' ) ) ) { | |
| 58 | + add_filter( | |
| 59 | + 'rest_request_before_callbacks', | |
| 60 | + array( static::class, 'normalize_rest_input_transport' ), | |
| 61 | + 5, | |
| 62 | + 3 | |
| 63 | + ); | |
| 64 | + } | |
| 65 | + | |
| 43 | 66 | // Hook into MainWP REST authentication to include Abilities API routes. |
| 44 | 67 | // This allows consumer_key/consumer_secret authentication to work for abilities. |
| 45 | 68 | add_filter( |
| 46 | 69 | 'mainwp_rest_is_request_to_rest_api', |
| @@ -76,8 +99,165 @@ | ||
| 76 | 99 | // Abilities API has its own permission_callback that checks current_user_can() |
| 77 | 100 | // which works with both MainWP API keys (via MainWP_REST_Authentication) and |
| 78 | 101 | // WordPress Application Passwords (via native WP REST auth). |
| 79 | 102 | return $is_mainwp_api; |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * Restore typed JSON values before the Abilities API validates a request. | |
| 107 | + * | |
| 108 | + * WordPress reads GET and DELETE ability input only from query parameters. | |
| 109 | + * Query parsing cannot distinguish JSON null from strings without a JSON | |
| 110 | + * carrier, and credentials must not be placed in DELETE URLs. This adapter | |
| 111 | + * is limited to MainWP ability run routes and leaves the legacy bracket-style | |
| 112 | + * input query parameter unchanged when no JSON carrier is present. | |
| 113 | + * | |
| 114 | + * @param mixed $response Result to send to the client, or null. | |
| 115 | + * @param array $handler Matched REST route handler. | |
| 116 | + * @param \WP_REST_Request $request Current REST request. | |
| 117 | + * @return mixed | |
| 118 | + */ | |
| 119 | + public static function normalize_rest_input_transport( $response, array $handler, \WP_REST_Request $request ) { | |
| 120 | + unset( $handler ); | |
| 121 | + | |
| 122 | + if ( null !== $response ) { | |
| 123 | + return $response; | |
| 124 | + } | |
| 125 | + | |
| 126 | + if ( ! preg_match( '#^/wp-abilities/v1/abilities/mainwp/[a-z0-9-]+/run$#', $request->get_route() ) ) { | |
| 127 | + return $response; | |
| 128 | + } | |
| 129 | + | |
| 130 | + $method = strtoupper( $request->get_method() ); | |
| 131 | + if ( 'GET' === $method ) { | |
| 132 | + return self::normalize_get_input_transport( $response, $request ); | |
| 133 | + } | |
| 134 | + | |
| 135 | + if ( 'DELETE' === $method ) { | |
| 136 | + return self::normalize_delete_input_transport( $response, $request ); | |
| 137 | + } | |
| 138 | + | |
| 139 | + return $response; | |
| 140 | + } | |
| 141 | + | |
| 142 | + /** | |
| 143 | + * Normalize the GET input_json query carrier. | |
| 144 | + * | |
| 145 | + * @param mixed $response Result to send to the client, or null. | |
| 146 | + * @param \WP_REST_Request $request Current REST request. | |
| 147 | + * @return mixed | |
| 148 | + */ | |
| 149 | + private static function normalize_get_input_transport( $response, \WP_REST_Request $request ) { | |
| 150 | + $query = $request->get_query_params(); | |
| 151 | + if ( ! array_key_exists( 'input_json', $query ) ) { | |
| 152 | + return $response; | |
| 153 | + } | |
| 154 | + | |
| 155 | + if ( array_key_exists( 'input', $query ) ) { | |
| 156 | + return self::invalid_input_transport(); | |
| 157 | + } | |
| 158 | + | |
| 159 | + $input = self::decode_input_object( $query['input_json'], self::MAX_GET_INPUT_BYTES ); | |
| 160 | + if ( is_wp_error( $input ) ) { | |
| 161 | + return $input; | |
| 162 | + } | |
| 163 | + | |
| 164 | + unset( $query['input_json'] ); | |
| 165 | + $query['input'] = $input; | |
| 166 | + $request->set_query_params( $query ); | |
| 167 | + | |
| 168 | + return $response; | |
| 169 | + } | |
| 170 | + | |
| 171 | + /** | |
| 172 | + * Normalize the DELETE application/json body carrier. | |
| 173 | + * | |
| 174 | + * @param mixed $response Result to send to the client, or null. | |
| 175 | + * @param \WP_REST_Request $request Current REST request. | |
| 176 | + * @return mixed | |
| 177 | + */ | |
| 178 | + private static function normalize_delete_input_transport( $response, \WP_REST_Request $request ) { | |
| 179 | + $body = $request->get_body(); | |
| 180 | + $query = $request->get_query_params(); | |
| 181 | + | |
| 182 | + if ( '' === trim( $body ) ) { | |
| 183 | + if ( array_key_exists( 'input_json', $query ) ) { | |
| 184 | + return self::invalid_input_transport(); | |
| 185 | + } | |
| 186 | + return $response; | |
| 187 | + } | |
| 188 | + | |
| 189 | + if ( array_key_exists( 'input', $query ) || array_key_exists( 'input_json', $query ) ) { | |
| 190 | + return self::invalid_input_transport(); | |
| 191 | + } | |
| 192 | + | |
| 193 | + $content_type = $request->get_content_type(); | |
| 194 | + if ( ! is_array( $content_type ) || 'application/json' !== $content_type['value'] ) { | |
| 195 | + return self::invalid_input_transport(); | |
| 196 | + } | |
| 197 | + | |
| 198 | + if ( strlen( $body ) > self::MAX_DELETE_INPUT_BYTES ) { | |
| 199 | + return self::invalid_input_transport(); | |
| 200 | + } | |
| 201 | + | |
| 202 | + $shape = json_decode( $body, false, 32 ); | |
| 203 | + $decoded = json_decode( $body, true, 32 ); | |
| 204 | + if ( | |
| 205 | + JSON_ERROR_NONE !== json_last_error() | |
| 206 | + || ! $shape instanceof \stdClass | |
| 207 | + || array( 'input' ) !== array_keys( get_object_vars( $shape ) ) | |
| 208 | + || ! $shape->input instanceof \stdClass | |
| 209 | + || ! is_array( $decoded ) | |
| 210 | + ) { | |
| 211 | + return self::invalid_input_transport(); | |
| 212 | + } | |
| 213 | + | |
| 214 | + $input = $decoded['input']; | |
| 215 | + if ( ! is_array( $input ) ) { | |
| 216 | + return self::invalid_input_transport(); | |
| 217 | + } | |
| 218 | + | |
| 219 | + $query['input'] = $input; | |
| 220 | + $request->set_query_params( $query ); | |
| 221 | + | |
| 222 | + return $response; | |
| 223 | + } | |
| 224 | + | |
| 225 | + /** | |
| 226 | + * Decode a bounded JSON object without coercing scalar values. | |
| 227 | + * | |
| 228 | + * @param mixed $raw Raw JSON value. | |
| 229 | + * @param int $max_bytes Maximum encoded size. | |
| 230 | + * @return array|\WP_Error | |
| 231 | + */ | |
| 232 | + private static function decode_input_object( $raw, int $max_bytes ) { | |
| 233 | + if ( ! is_string( $raw ) || strlen( $raw ) > $max_bytes ) { | |
| 234 | + return self::invalid_input_transport(); | |
| 235 | + } | |
| 236 | + | |
| 237 | + $decoded = json_decode( $raw, true, 32 ); | |
| 238 | + if ( | |
| 239 | + JSON_ERROR_NONE !== json_last_error() | |
| 240 | + || ! is_array( $decoded ) | |
| 241 | + || '{' !== substr( ltrim( $raw ), 0, 1 ) | |
| 242 | + ) { | |
| 243 | + return self::invalid_input_transport(); | |
| 244 | + } | |
| 245 | + | |
| 246 | + return $decoded; | |
| 247 | + } | |
| 248 | + | |
| 249 | + /** | |
| 250 | + * Return the closed, non-reflective transport error. | |
| 251 | + * | |
| 252 | + * @return \WP_Error | |
| 253 | + */ | |
| 254 | + private static function invalid_input_transport(): \WP_Error { | |
| 255 | + return new \WP_Error( | |
| 256 | + 'mainwp_abilities_invalid_input_transport', | |
| 257 | + __( 'The ability input transport is invalid.', 'mainwp' ), | |
| 258 | + array( 'status' => 400 ) | |
| 259 | + ); | |
| 80 | 260 | } |
| 81 | 261 | |
| 82 | 262 | /** |
| 83 | 263 | * Register MainWP ability categories. |