PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
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 +168 -2 3.19.3 → 3.24.2 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,8 +187,10 @@
165 187
166 188 /**
167 189 * Sends a request to the remote API.
168 190 *
191 + * Any error returned has its credentials stripped.
192 + *
169 193 * @since 3.17.0
170 194 *
171 195 * @param string $method The HTTP method to use for the request.
172 196 * @param array<mixed> $query_args The query arguments to send to the remote API.
@@ -193,15 +217,44 @@
193 217
194 218 /** @var WP_HTTP_Response|WP_Error $response */
195 219 $response = wp_safe_remote_request( $request_url, $request_options );
196 220
197 - return $this->process_response( $response );
221 + if ( is_wp_error( $response ) ) {
222 + return $this->get_relayable_transport_error( $response );
223 + }
224 +
225 + $result = $this->process_response( $response );
226 +
227 + return is_wp_error( $result ) ? $this->strip_credentials_from_error( $result ) : $result;
198 228 }
199 229
200 230 /**
231 + * Returns a transport error reduced to its code and first message, without
232 + * the credentials.
233 + *
234 + * Its other messages and data are dropped, as `pre_http_request` and other
235 + * filters can attach anything to it, including the request headers.
236 + *
237 + * @since 3.24.2
238 + *
239 + * @param WP_Error $error The transport error.
240 + * @return WP_Error The error to relay to the caller.
241 + */
242 + protected function get_relayable_transport_error( WP_Error $error ): WP_Error {
243 + return $this->strip_credentials_from_error(
244 + new WP_Error(
245 + $error->get_error_code(),
246 + $error->get_error_message(),
247 + array( 'status' => 500 )
248 + )
249 + );
250 + }
251 +
252 + /**
201 253 * Processes the response from the remote API.
202 254 *
203 255 * @since 3.17.0
256 + * @since 3.24.2 Added the upstream status code check.
204 257 *
205 258 * @param WP_HTTP_Response|WP_Error $response The response from the remote API.
206 259 * @return array<mixed>|WP_Error The processed response.
207 260 */
@@ -213,13 +266,126 @@
213 266
214 267 $body = wp_remote_retrieve_body( $response );
215 268 $decoded = json_decode( $body, true );
216 269
270 + // A parseable body does not mean the request succeeded.
271 + $status_code = (int) wp_remote_retrieve_response_code( $response );
272 + if ( 0 !== $status_code && ( $status_code < 200 || $status_code >= 300 ) ) {
273 + // The upstream message is kept, as request() strips the credentials.
274 + $message = is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) && '' !== $decoded['message']
275 + ? $decoded['message']
276 + : __( 'The upstream API returned an unsuccessful response', 'wp-parsely' );
277 +
278 + return new WP_Error( $status_code, $message, array( 'status' => $status_code ) );
279 + }
280 +
217 281 if ( ! is_array( $decoded ) ) {
218 282 return new WP_Error( 400, __( 'Unable to decode upstream API response', 'wp-parsely' ) );
219 283 }
220 284
221 285 return $decoded;
286 + }
287 +
288 + /**
289 + * Removes the credentials from a message relayed to the caller.
290 + *
291 + * Arguments are dropped by name rather than by value, so values altered by
292 + * the URL builder are covered too.
293 + *
294 + * @since 3.24.2
295 + *
296 + * @param string $message The message to strip.
297 + * @return string The message, without the credentials.
298 + */
299 + protected function strip_credentials( string $message ): string {
300 + if ( '' === $message ) {
301 + return $message;
302 + }
303 +
304 + foreach ( self::CREDENTIAL_QUERY_ARGS as $arg ) {
305 + $arg = preg_quote( $arg, '/' );
306 +
307 + // Keeps the separator when arguments follow, drops it when trailing.
308 + $message = (string) preg_replace(
309 + array( '/([?&])' . $arg . '=[^&\s]*&/i', '/[?&]' . $arg . '=[^&\s]*/i' ),
310 + array( '$1', '' ),
311 + $message
312 + );
313 + }
314 +
315 + // Also catches the secret outside a query string.
316 + $secret = $this->get_parsely()->get_api_secret();
317 + if ( '' !== $secret ) {
318 + $message = str_replace( $secret, '', $message );
319 + }
320 +
321 + // An encoded copy, such as a URL encoded by a filter, can't be stripped in place.
322 + if ( $this->has_encoded_credentials( $message, $secret ) ) {
323 + return __( 'The upstream API request failed.', 'wp-parsely' );
324 + }
325 +
326 + return $message;
327 + }
328 +
329 + /**
330 + * Returns whether the URL-decoded or JSON-unescaped forms of a message
331 + * still contain a credential.
332 + *
333 + * @since 3.24.2
334 + *
335 + * @param string $message The message, already stripped of its plain credentials.
336 + * @param string $secret The API Secret.
337 + * @return bool Whether the message holds an encoded credential.
338 + */
339 + private function has_encoded_credentials( string $message, string $secret ): bool {
340 + $decoded = $message;
341 +
342 + // Up to triple encoding, as each layer of code relaying a URL can encode it again.
343 + for ( $i = 0; $i < 3; $i++ ) {
344 + $decoded = str_replace( '\\/', '/', rawurldecode( $decoded ) );
345 +
346 + if ( '' !== $secret && false !== strpos( $decoded, $secret ) ) {
347 + return true;
348 + }
349 +
350 + foreach ( self::CREDENTIAL_QUERY_ARGS as $arg ) {
351 + if ( 1 === preg_match( '/[?&]' . preg_quote( $arg, '/' ) . '=/i', $decoded ) ) {
352 + return true;
353 + }
354 + }
355 + }
356 +
357 + return false;
358 + }
359 +
360 + /**
361 + * Removes the credentials from every message held by an error.
362 + *
363 + * @since 3.24.2
364 + *
365 + * @param WP_Error $error The error to strip.
366 + * @return WP_Error The error, without the credentials.
367 + */
368 + protected function strip_credentials_from_error( WP_Error $error ): WP_Error {
369 + $codes = $error->get_error_codes();
370 +
371 + if ( 0 === count( $codes ) ) {
372 + return $error;
373 + }
374 +
375 + $stripped = new WP_Error();
376 +
377 + foreach ( $codes as $code ) {
378 + foreach ( $error->get_error_messages( $code ) as $message ) {
379 + $stripped->add(
380 + $code,
381 + $this->strip_credentials( $message ),
382 + $error->get_error_data( $code )
383 + );
384 + }
385 + }
386 +
387 + return $stripped;
222 388 }
223 389
224 390 /**
225 391 * Returns the Parsely instance.