PluginProbe
Parse.ly / 3.24.3
Parse.ly v3.24.3
3.24.3 3.24.2 3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 All 107 releases
← All changes | src/services/class-base-service-endpoint.php +174 -3 3.24.0 → 3.24.3 View file →
@@ -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.