| @@ -63,8 +63,19 @@ | ||
| 63 | 63 | */ |
| 64 | 64 | protected const TRUNCATE_CONTENT_LENGTH = 25000; |
| 65 | 65 | |
| 66 | 66 | /** |
| 67 | + * The query arguments removed from relayed messages. | |
| 68 | + * | |
| 69 | + * The Site ID isn't listed, as it's public and aids diagnosis. | |
| 70 | + * | |
| 71 | + * @since 3.24.2 | |
| 72 | + * | |
| 73 | + * @var array<string> | |
| 74 | + */ | |
| 75 | + private const CREDENTIAL_QUERY_ARGS = array( 'secret' ); | |
| 76 | + | |
| 77 | + /** | |
| 67 | 78 | * Initializes the class. |
| 68 | 79 | * |
| 69 | 80 | * @since 3.17.0 |
| 70 | 81 | * |
| @@ -145,8 +156,9 @@ | ||
| 145 | 156 | /** |
| 146 | 157 | * Returns the full URL for the API request, including the endpoint and query arguments. |
| 147 | 158 | * |
| 148 | 159 | * @since 3.17.0 |
| 160 | + * @since 3.24.2 Encodes the query argument values. | |
| 149 | 161 | * |
| 150 | 162 | * @param array<mixed> $query_args The query arguments to send to the remote API. |
| 151 | 163 | * @return string The full URL for the API request. |
| 152 | 164 | */ |
| @@ -156,10 +168,20 @@ | ||
| 156 | 168 | |
| 157 | 169 | // Append the endpoint to the base URL. |
| 158 | 170 | $base_url .= $this->get_endpoint(); |
| 159 | 171 | |
| 172 | + $query_args = $this->get_query_args( $query_args ); | |
| 173 | + | |
| 174 | + // add_query_arg() doesn't encode values, so `&` or `#` in one would alter the query. | |
| 175 | + // Decoding first keeps already-encoded values, like non-Latin permalinks, from being encoded twice. | |
| 176 | + foreach ( $query_args as $key => $value ) { | |
| 177 | + if ( is_string( $value ) ) { | |
| 178 | + $query_args[ $key ] = rawurlencode( rawurldecode( $value ) ); | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 160 | 182 | // Append any necessary query arguments. |
| 161 | - $endpoint = add_query_arg( $this->get_query_args( $query_args ), $base_url ); | |
| 183 | + $endpoint = add_query_arg( $query_args, $base_url ); | |
| 162 | 184 | |
| 163 | 185 | return $endpoint; |
| 164 | 186 | } |
| 165 | 187 | |
| @@ -165,9 +187,13 @@ | ||
| 165 | 187 | |
| 166 | 188 | /** |
| 167 | 189 | * Sends a request to the remote API. |
| 168 | 190 | * |
| 191 | + * Any error returned has its credentials stripped. Endpoints customize the | |
| 192 | + * request through the methods called here, rather than by overriding it. | |
| 193 | + * | |
| 169 | 194 | * @since 3.17.0 |
| 195 | + * @since 3.24.3 Made final. | |
| 170 | 196 | * |
| 171 | 197 | * @param string $method The HTTP method to use for the request. |
| 172 | 198 | * @param array<mixed> $query_args The query arguments to send to the remote API. |
| 173 | 199 | * @param array<mixed> $data The data to send in the request body. |
| @@ -172,9 +198,9 @@ | ||
| 172 | 198 | * @param array<mixed> $query_args The query arguments to send to the remote API. |
| 173 | 199 | * @param array<mixed> $data The data to send in the request body. |
| 174 | 200 | * @return WP_Error|array<mixed> The response from the remote API. |
| 175 | 201 | */ |
| 176 | - protected function request( string $method, array $query_args = array(), array $data = array() ) { | |
| 202 | + final protected function request( string $method, array $query_args = array(), array $data = array() ) { | |
| 177 | 203 | // Get the URL to send the request to. |
| 178 | 204 | $request_url = $this->get_endpoint_url( $query_args ); |
| 179 | 205 | |
| 180 | 206 | // Build the request options. |
| @@ -193,15 +219,45 @@ | ||
| 193 | 219 | |
| 194 | 220 | /** @var WP_HTTP_Response|WP_Error $response */ |
| 195 | 221 | $response = wp_safe_remote_request( $request_url, $request_options ); |
| 196 | 222 | |
| 197 | - return $this->process_response( $response ); | |
| 223 | + if ( is_wp_error( $response ) ) { | |
| 224 | + return $this->get_relayable_transport_error( $response ); | |
| 225 | + } | |
| 226 | + | |
| 227 | + $result = $this->process_response( $response ); | |
| 228 | + | |
| 229 | + return is_wp_error( $result ) ? $this->strip_credentials_from_error( $result ) : $result; | |
| 198 | 230 | } |
| 199 | 231 | |
| 200 | 232 | /** |
| 233 | + * Returns a transport error reduced to its code and first message, without | |
| 234 | + * the credentials. | |
| 235 | + * | |
| 236 | + * Its other messages and data are dropped, as `pre_http_request` and other | |
| 237 | + * filters can attach anything to it, including the request headers. | |
| 238 | + * | |
| 239 | + * @since 3.24.2 | |
| 240 | + * @since 3.24.3 Made final. | |
| 241 | + * | |
| 242 | + * @param WP_Error $error The transport error. | |
| 243 | + * @return WP_Error The error to relay to the caller. | |
| 244 | + */ | |
| 245 | + final protected function get_relayable_transport_error( WP_Error $error ): WP_Error { | |
| 246 | + return $this->strip_credentials_from_error( | |
| 247 | + new WP_Error( | |
| 248 | + $error->get_error_code(), | |
| 249 | + $error->get_error_message(), | |
| 250 | + array( 'status' => 500 ) | |
| 251 | + ) | |
| 252 | + ); | |
| 253 | + } | |
| 254 | + | |
| 255 | + /** | |
| 201 | 256 | * Processes the response from the remote API. |
| 202 | 257 | * |
| 203 | 258 | * @since 3.17.0 |
| 259 | + * @since 3.24.2 Added the upstream status code check. | |
| 204 | 260 | * |
| 205 | 261 | * @param WP_HTTP_Response|WP_Error $response The response from the remote API. |
| 206 | 262 | * @return array<mixed>|WP_Error The processed response. |
| 207 | 263 | */ |
| @@ -213,13 +269,128 @@ | ||
| 213 | 269 | |
| 214 | 270 | $body = wp_remote_retrieve_body( $response ); |
| 215 | 271 | $decoded = json_decode( $body, true ); |
| 216 | 272 | |
| 273 | + // A parseable body does not mean the request succeeded. | |
| 274 | + $status_code = (int) wp_remote_retrieve_response_code( $response ); | |
| 275 | + if ( 0 !== $status_code && ( $status_code < 200 || $status_code >= 300 ) ) { | |
| 276 | + // The upstream message is kept, as request() strips the credentials. | |
| 277 | + $message = is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) && '' !== $decoded['message'] | |
| 278 | + ? $decoded['message'] | |
| 279 | + : __( 'The upstream API returned an unsuccessful response', 'wp-parsely' ); | |
| 280 | + | |
| 281 | + return new WP_Error( $status_code, $message, array( 'status' => $status_code ) ); | |
| 282 | + } | |
| 283 | + | |
| 217 | 284 | if ( ! is_array( $decoded ) ) { |
| 218 | 285 | return new WP_Error( 400, __( 'Unable to decode upstream API response', 'wp-parsely' ) ); |
| 219 | 286 | } |
| 220 | 287 | |
| 221 | 288 | return $decoded; |
| 289 | + } | |
| 290 | + | |
| 291 | + /** | |
| 292 | + * Removes the credentials from a message relayed to the caller. | |
| 293 | + * | |
| 294 | + * Arguments are dropped by name rather than by value, so values altered by | |
| 295 | + * the URL builder are covered too. | |
| 296 | + * | |
| 297 | + * @since 3.24.2 | |
| 298 | + * @since 3.24.3 Made final. | |
| 299 | + * | |
| 300 | + * @param string $message The message to strip. | |
| 301 | + * @return string The message, without the credentials. | |
| 302 | + */ | |
| 303 | + final protected function strip_credentials( string $message ): string { | |
| 304 | + if ( '' === $message ) { | |
| 305 | + return $message; | |
| 306 | + } | |
| 307 | + | |
| 308 | + foreach ( self::CREDENTIAL_QUERY_ARGS as $arg ) { | |
| 309 | + $arg = preg_quote( $arg, '/' ); | |
| 310 | + | |
| 311 | + // Keeps the separator when arguments follow, drops it when trailing. | |
| 312 | + $message = (string) preg_replace( | |
| 313 | + array( '/([?&])' . $arg . '=[^&\s]*&/i', '/[?&]' . $arg . '=[^&\s]*/i' ), | |
| 314 | + array( '$1', '' ), | |
| 315 | + $message | |
| 316 | + ); | |
| 317 | + } | |
| 318 | + | |
| 319 | + // Also catches the secret outside a query string. | |
| 320 | + $secret = $this->get_parsely()->get_api_secret(); | |
| 321 | + if ( '' !== $secret ) { | |
| 322 | + $message = str_replace( $secret, '', $message ); | |
| 323 | + } | |
| 324 | + | |
| 325 | + // An encoded copy, such as a URL encoded by a filter, can't be stripped in place. | |
| 326 | + if ( $this->has_encoded_credentials( $message, $secret ) ) { | |
| 327 | + return __( 'The upstream API request failed.', 'wp-parsely' ); | |
| 328 | + } | |
| 329 | + | |
| 330 | + return $message; | |
| 331 | + } | |
| 332 | + | |
| 333 | + /** | |
| 334 | + * Returns whether the URL-decoded or JSON-unescaped forms of a message | |
| 335 | + * still contain a credential. | |
| 336 | + * | |
| 337 | + * @since 3.24.2 | |
| 338 | + * | |
| 339 | + * @param string $message The message, already stripped of its plain credentials. | |
| 340 | + * @param string $secret The API Secret. | |
| 341 | + * @return bool Whether the message holds an encoded credential. | |
| 342 | + */ | |
| 343 | + private function has_encoded_credentials( string $message, string $secret ): bool { | |
| 344 | + $decoded = $message; | |
| 345 | + | |
| 346 | + // Up to triple encoding, as each layer of code relaying a URL can encode it again. | |
| 347 | + for ( $i = 0; $i < 3; $i++ ) { | |
| 348 | + $decoded = str_replace( '\\/', '/', rawurldecode( $decoded ) ); | |
| 349 | + | |
| 350 | + if ( '' !== $secret && false !== strpos( $decoded, $secret ) ) { | |
| 351 | + return true; | |
| 352 | + } | |
| 353 | + | |
| 354 | + foreach ( self::CREDENTIAL_QUERY_ARGS as $arg ) { | |
| 355 | + if ( 1 === preg_match( '/[?&]' . preg_quote( $arg, '/' ) . '=/i', $decoded ) ) { | |
| 356 | + return true; | |
| 357 | + } | |
| 358 | + } | |
| 359 | + } | |
| 360 | + | |
| 361 | + return false; | |
| 362 | + } | |
| 363 | + | |
| 364 | + /** | |
| 365 | + * Removes the credentials from every message held by an error. | |
| 366 | + * | |
| 367 | + * @since 3.24.2 | |
| 368 | + * @since 3.24.3 Made final. | |
| 369 | + * | |
| 370 | + * @param WP_Error $error The error to strip. | |
| 371 | + * @return WP_Error The error, without the credentials. | |
| 372 | + */ | |
| 373 | + final protected function strip_credentials_from_error( WP_Error $error ): WP_Error { | |
| 374 | + $codes = $error->get_error_codes(); | |
| 375 | + | |
| 376 | + if ( 0 === count( $codes ) ) { | |
| 377 | + return $error; | |
| 378 | + } | |
| 379 | + | |
| 380 | + $stripped = new WP_Error(); | |
| 381 | + | |
| 382 | + foreach ( $codes as $code ) { | |
| 383 | + foreach ( $error->get_error_messages( $code ) as $message ) { | |
| 384 | + $stripped->add( | |
| 385 | + $code, | |
| 386 | + $this->strip_credentials( $message ), | |
| 387 | + $error->get_error_data( $code ) | |
| 388 | + ); | |
| 389 | + } | |
| 390 | + } | |
| 391 | + | |
| 392 | + return $stripped; | |
| 222 | 393 | } |
| 223 | 394 | |
| 224 | 395 | /** |
| 225 | 396 | * Returns the Parsely instance. |