From be81f7eabf3552a416da24003f8079b7d033af29 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 16 Sep 2024 16:00:46 +0800 Subject: [PATCH] Create a wrapper function for all external dependency function calls for safety --- .../src/Internal/Logging/RemoteLogger.php | 125 +++--- .../Logging/UseNonBuiltInFunctions.php | 413 ++++++++++++++++++ .../src/Internal/Logging/RemoteLoggerTest.php | 7 +- 3 files changed, 479 insertions(+), 66 deletions(-) create mode 100644 plugins/woocommerce/src/Internal/Logging/UseNonBuiltInFunctions.php diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php index 19a04d84fec..bf3febac784 100644 --- a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php +++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php @@ -6,9 +6,10 @@ namespace Automattic\WooCommerce\Internal\Logging; use Automattic\WooCommerce\Utilities\FeaturesUtil; use Automattic\WooCommerce\Utilities\StringUtil; use Automattic\WooCommerce\Internal\McStats; +use Jetpack_Options; use WC_Rate_Limiter; use WC_Log_Levels; -use Jetpack_Options; +use WC_Site_Tracking; /** * WooCommerce Remote Logger @@ -22,6 +23,8 @@ use Jetpack_Options; * @package WooCommerce\Classes */ class RemoteLogger extends \WC_Log_Handler { + use UseNonBuiltInFunctions; + const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash'; const RATE_LIMIT_ID = 'woocommerce_remote_logging'; const RATE_LIMIT_DELAY = 60; // 1 minute. @@ -40,11 +43,17 @@ class RemoteLogger extends \WC_Log_Handler { * @return bool False if value was not handled and true if value was handled. */ public function handle( $timestamp, $level, $message, $context ) { - if ( ! $this->should_handle( $level, $message, $context ) ) { + try { + if ( ! $this->should_handle( $level, $message, $context ) ) { + return false; + } + + return $this->log( $level, $message, $context ); + } catch ( \Throwable $e ) { + // Log the error to the local logger so we can investigate. + $this->wc_get_logger()->error( 'Failed to handle the log: ' . $e->getMessage(), array( 'source' => 'remote-logging' ) ); return false; } - - return $this->log( $level, $message, $context ); } /** @@ -66,14 +75,14 @@ class RemoteLogger extends \WC_Log_Handler { 'feature' => 'woocommerce_core', 'severity' => $level, 'message' => $this->sanitize( $message ), - 'host' => wp_parse_url( home_url(), PHP_URL_HOST ), + 'host' => $this->wp_parse_url( $this->home_url(), PHP_URL_HOST ), 'tags' => array( 'woocommerce', 'php' ), 'properties' => array( - 'wc_version' => WC()->version, + 'wc_version' => $this->get_wc_version(), 'php_version' => phpversion(), - 'wp_version' => get_bloginfo( 'version' ), + 'wp_version' => $this->get_bloginfo( 'version' ), 'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ), - 'store_id' => get_option( \WC_Install::STORE_ID_OPTION, null ), + 'store_id' => $this->get_option( \WC_Install::STORE_ID_OPTION, null ), ), ); @@ -140,7 +149,7 @@ class RemoteLogger extends \WC_Log_Handler { return false; } - if ( ! \WC_Site_Tracking::is_tracking_enabled() ) { + if ( ! WC_Site_Tracking::is_tracking_enabled() ) { return false; } @@ -193,11 +202,12 @@ class RemoteLogger extends \WC_Log_Handler { } if ( WC_Rate_Limiter::retried_too_soon( self::RATE_LIMIT_ID ) ) { - error_log( 'Remote logging throttled.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + // Log locally that the remote logging is throttled. + $this->wc_get_logger()->warning( 'Remote logging throttled.', array( 'source' => 'remote-logging' ) ); return false; } - return true; + return true; } @@ -212,46 +222,41 @@ class RemoteLogger extends \WC_Log_Handler { * @return bool */ private function log( $level, $message, $context ) { - try { - $log_data = $this->get_formatted_log( $level, $message, $context ); + $log_data = $this->get_formatted_log( $level, $message, $context ); // Ensure the log data is valid. - if ( ! is_array( $log_data ) || empty( $log_data['message'] ) || empty( $log_data['feature'] ) ) { - return false; - } - - $body = array( - 'params' => wp_json_encode( $log_data ), - ); - - WC_Rate_Limiter::set_rate_limit( self::RATE_LIMIT_ID, self::RATE_LIMIT_DELAY ); - - if ( $this->is_dev_or_local_environment() ) { - return false; - } - - $response = wp_safe_remote_post( - self::LOG_ENDPOINT, - array( - 'body' => wp_json_encode( $body ), - 'timeout' => 3, - 'headers' => array( - 'Content-Type' => 'application/json', - ), - 'blocking' => false, - ) - ); - - if ( is_wp_error( $response ) ) { - throw new \Exception( $response->get_error_message() ); - } - - return true; - } catch ( \Throwable $e ) { - // Log the error locally if the remote logging fails. - error_log( 'Remote logging failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + if ( ! is_array( $log_data ) || empty( $log_data['message'] ) || empty( $log_data['feature'] ) ) { return false; } + + $body = array( + 'params' => $this->wp_json_encode( $log_data ), + ); + + WC_Rate_Limiter::set_rate_limit( self::RATE_LIMIT_ID, self::RATE_LIMIT_DELAY ); + + if ( $this->is_dev_or_local_environment() ) { + return false; + } + + $response = $this->wp_safe_remote_post( + self::LOG_ENDPOINT, + array( + 'body' => $this->wp_json_encode( $body ), + 'timeout' => 3, + 'headers' => array( + 'Content-Type' => 'application/json', + ), + 'blocking' => false, + ) + ); + + if ( is_wp_error( $response ) ) { + $this->wc_get_logger()->error( 'Failed to send the log to the remote logging service: ' . $response->get_error_message(), array( 'source' => 'remote-logging' ) ); + return false; + } + + return true; } /** @@ -260,7 +265,7 @@ class RemoteLogger extends \WC_Log_Handler { * @return bool */ private function is_variant_assignment_allowed() { - $assignment = get_option( 'woocommerce_remote_variant_assignment', 0 ); + $assignment = $this->get_option( 'woocommerce_remote_variant_assignment', 0 ); return ( $assignment <= 12 ); // Considering 10% of the 0-120 range. } @@ -270,12 +275,12 @@ class RemoteLogger extends \WC_Log_Handler { * @return bool */ private function should_current_version_be_logged() { - $new_version = get_site_transient( self::WC_NEW_VERSION_TRANSIENT ); + $new_version = $this->get_site_transient( self::WC_NEW_VERSION_TRANSIENT ); if ( false === $new_version ) { $new_version = $this->fetch_new_woocommerce_version(); // Cache the new version for a week since we want to keep logging in with the same version for a while even if the new version is available. - set_site_transient( self::WC_NEW_VERSION_TRANSIENT, $new_version, WEEK_IN_SECONDS ); + $this->set_site_transient( self::WC_NEW_VERSION_TRANSIENT, $new_version, WEEK_IN_SECONDS ); } if ( ! is_string( $new_version ) || '' === $new_version ) { @@ -284,7 +289,7 @@ class RemoteLogger extends \WC_Log_Handler { } // If the current version is the latest, we don't want to log errors. - return version_compare( WC()->version, $new_version, '>=' ); + return version_compare( $this->get_wc_version(), $new_version, '>=' ); } /** @@ -324,6 +329,9 @@ class RemoteLogger extends \WC_Log_Handler { } } + if ( ! function_exists( 'apply_filters' ) ) { + require_once ABSPATH . WPINC . '/plugin.php'; + } /** * Filter to allow other plugins to overwrite the result of the third-party error check for remote logging. * @@ -342,14 +350,7 @@ class RemoteLogger extends \WC_Log_Handler { * @return string|null New version if an update is available, null otherwise. */ private function fetch_new_woocommerce_version() { - if ( ! function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; - } - if ( ! function_exists( 'get_plugin_updates' ) ) { - require_once ABSPATH . 'wp-admin/includes/update.php'; - } - - $plugin_updates = get_plugin_updates(); + $plugin_updates = $this->get_plugin_updates(); // Check if WooCommerce plugin update information is available. if ( ! is_array( $plugin_updates ) || ! isset( $plugin_updates[ WC_PLUGIN_BASENAME ] ) ) { @@ -429,7 +430,7 @@ class RemoteLogger extends \WC_Log_Handler { $is_array_by_file = isset( $sanitized_trace[0]['file'] ); if ( $is_array_by_file ) { - return wc_print_r( $sanitized_trace, true ); + return $this->wc_print_r( $sanitized_trace, true ); } return implode( "\n", $sanitized_trace ); @@ -443,7 +444,7 @@ class RemoteLogger extends \WC_Log_Handler { * @return bool */ protected function is_dev_or_local_environment() { - return in_array( wp_get_environment_type(), array( 'development', 'local' ), true ); + return in_array( $this->wp_get_environment_type(), array( 'development', 'local' ), true ); } /** * Sanitize the request URI to only allow certain query parameters. @@ -474,7 +475,7 @@ class RemoteLogger extends \WC_Log_Handler { */ $whitelist = apply_filters( 'woocommerce_remote_logger_request_uri_whitelist', $default_whitelist ); - $parsed_url = wp_parse_url( $request_uri ); + $parsed_url = $this->wp_parse_url( $request_uri ); if ( ! isset( $parsed_url['query'] ) ) { return $request_uri; } diff --git a/plugins/woocommerce/src/Internal/Logging/UseNonBuiltInFunctions.php b/plugins/woocommerce/src/Internal/Logging/UseNonBuiltInFunctions.php new file mode 100644 index 00000000000..20483657dec --- /dev/null +++ b/plugins/woocommerce/src/Internal/Logging/UseNonBuiltInFunctions.php @@ -0,0 +1,413 @@ +log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'url' => $url, + 'component' => $component, + ) + ); + + return null; + } + } + + /** + * Home_url wrapper. + * + * @param string $path The path to append to the home URL. + * @param string $scheme The scheme to use for the home URL. + * @return string The home URL with the path appended. + */ + private function home_url( $path = '', $scheme = null ) { + try { + if ( ! function_exists( 'home_url' ) ) { + require_once ABSPATH . WPINC . '/link-template.php'; + } + + return home_url( $path, $scheme ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'path' => $path, + 'scheme' => $scheme, + ) + ); + + return 'Unable to retrieve'; + } + } + + + /** + * Get_bloginfo wrapper. + * + * @param string $show Optional. Site info to retrieve. Default empty (site name). + * @param string $filter Optional. How to filter what is retrieved. Default 'raw'. + * @return string Mostly string values, might be empty. + */ + private function get_bloginfo( $show = '', $filter = 'raw' ) { + try { + if ( ! function_exists( 'get_bloginfo' ) ) { + require_once ABSPATH . WPINC . '/general-template.php'; + } + + return get_bloginfo( $show, $filter ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'show' => $show, + 'filter' => $filter, + ) + ); + + return 'Unable to retrieve'; + } + } + + /** + * Get_option wrapper. + * + * @param string $option Name of the option to retrieve. Expected to not be SQL-escaped. + * @param mixed $_default Optional. Default value to return if the option does not exist. + * @return mixed Value of the option. A value of any type may be returned, including + * scalar (string, boolean, float, integer), null, array, object. + * Scalar and null values will be returned as strings as long as they originate + * from a database stored option value. If there is no option in the database, + * boolean `false` is returned. + */ + private function get_option( $option, $_default = false ) { + try { + if ( ! function_exists( 'get_option' ) ) { + require_once ABSPATH . WPINC . '/option.php'; + } + + return get_option( $option, $_default ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'option' => $option, + '_default' => $_default, + ) + ); + return 'Unable to retrieve'; + } + } + + + /** + * Get_site_transient wrapper. + * + * @param string $transient Transient name. Expected to not be SQL-escaped. + * @return mixed Value of the transient. + */ + private function get_site_transient( $transient ) { + if ( ! function_exists( 'get_site_transient' ) ) { + require_once ABSPATH . WPINC . '/option.php'; + } + + try { + return get_site_transient( $transient ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'transient' => $transient, + ) + ); + return 'Unable to retrieve'; + } + } + + /** + * Set_site_transient wrapper. + * + * @param string $transient Transient name. Expected to not be SQL-escaped. + * @param mixed $value Value of the transient. + * @param int $expiration Optional. Time until expiration in seconds. Default 0 (no expiration). + * @return bool True if the transient was set successfully, false otherwise. + */ + private function set_site_transient( $transient, $value, $expiration = 0 ) { + try { + if ( ! function_exists( 'set_site_transient' ) ) { + require_once ABSPATH . WPINC . '/option.php'; + } + + return set_site_transient( $transient, $value, $expiration ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'transient' => $transient, + 'value' => $value, + 'expiration' => $expiration, + ) + ); + return false; + } + } + + /** + * Wp_safe_remote_post wrapper. + * + * @param string $url URL to send the request to. + * @param array $args Optional. Additional arguments for the request. + * @return array|WP_Error The response or WP_Error on failure. + * + * @throws \Exception If wp_safe_remote_post function does not exist. + */ + private function wp_safe_remote_post( $url, $args = array() ) { + try { + if ( ! function_exists( 'wp_safe_remote_post' ) ) { + require_once ABSPATH . WPINC . '/http.php'; + } + + return wp_safe_remote_post( $url, $args ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'url' => $url, + 'args' => $args, + ) + ); + + return new \WP_Error( 'remote_post_error', $e->getMessage() ); + } + } + + + /** + * Is_wp_error wrapper. + * + * @param mixed $thing The variable to check. + * @return bool Whether the variable is an instance of WP_Error. + * + * @throws \Exception If is_wp_error function does not exist. + */ + private function is_wp_error( $thing ) { + try { + if ( ! function_exists( 'is_wp_error' ) ) { + require_once ABSPATH . WPINC . '/load.php'; + } + + return is_wp_error( $thing ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'thing' => $thing, + ) + ); + + // We can't determine if the variable is an instance of WP_Error so we throw an exception. + throw new \Exception( 'is_wp_error function does not exist' ); + } + } + + /** + * Get_plugin_updates wrapper. + * + * @return array|null The plugin updates array or null if not available. + */ + private function get_plugin_updates() { + try { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + if ( ! function_exists( 'get_plugin_updates' ) ) { + require_once ABSPATH . 'wp-admin/includes/update.php'; + } + + return get_plugin_updates(); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array() + ); + + // If the function does not exist, return null since the update is not available. + return null; + } + } + + /** + * Get_wp_environment_type wrapper. + * + * @return string The environment type. + * + * @throws \Exception If wp_get_environment_type function does not exist. + */ + private function wp_get_environment_type() { + try { + if ( ! function_exists( 'wp_get_environment_type' ) ) { + require_once ABSPATH . WPINC . '/load.php'; + } + + return wp_get_environment_type(); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array() + ); + + // If the function does not exist, return production since the wp default value is production. + return 'production'; + } + } + + /** + * Wp_json_encode wrapper. + * + * @param mixed $data The data to encode. + * @return string The JSON encoded string. + * + * @throws \Exception If wp_json_encode function does not exist. + */ + private function wp_json_encode( $data ) { + try { + if ( ! function_exists( 'wp_json_encode' ) ) { + require_once ABSPATH . WPINC . '/functions.php'; + } + + return wp_json_encode( $data ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array( + 'data' => $data, + ) + ); + + return 'Unable to encode'; + } + } + + /** + * Get_wc_version wrapper. + * + * @return string The WooCommerce version. + * + * @throws \Exception If get_wc_version function does not exist. + */ + private function get_wc_version() { + try { + return Constants::get_constant( 'WC_VERSION' ); + } catch ( \Throwable $e ) { + $this->log_wrapper_error( + __FUNCTION__, + $e->getMessage(), + array() + ); + + return 'Unable to retrieve'; + } + } + + /** + * Wc_get_logger wrapper. + * + * @return \WC_Logger The WooCommerce logger. + * + * @throws \Exception If wc_get_logger function does not exist. + */ + private function wc_get_logger() { + try { + if ( ! function_exists( 'wc_get_logger' ) ) { + require_once WC_ABSPATH . 'includes/class-wc-logger.php'; + } + + return wc_get_logger(); + } catch ( \Throwable $e ) { + throw new \Exception( 'wc_get_logger function does not exist' ); + } + } + + /** + * Wc_print_r wrapper. + * + * @param mixed $data The data to print. + * @param bool $_return Whether to return the output instead of printing it. + * @return string The printed data. + */ + private function wc_print_r( $data, $_return = false ) { + if ( ! function_exists( 'wc_print_r' ) ) { + require_once WC_ABSPATH . 'includes/wc-core-functions.php'; + } + + return wc_print_r( $data, $_return ); + } + + + /** + * Log wrapper function errors to "local logging" for debugging. + * + * @param string $function_name The name of the wrapped function. + * @param string $error_message The error message. + * @param array $context Additional context for the error. + */ + private function log_wrapper_error( $function_name, $error_message, $context = array() ) { + $this->wc_get_logger()->error( + '[Wrapper function error] ' . sprintf( 'Error in %s: %s', $function_name, $error_message ), + array_merge( + array( + 'function' => $function_name, + 'source' => 'remote-logging', + ), + $context + ) + ); + } +} diff --git a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php index 88218f1da75..ffa45362007 100644 --- a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php @@ -7,6 +7,7 @@ declare( strict_types = 1 ); // phpcs:disable Universal.Namespaces.OneDeclarationPerFile.MultipleFound -- same namespace Automattic\WooCommerce\Tests\Internal\Logging { + use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Internal\Logging\RemoteLogger; use WC_Rate_Limiter; use WC_Cache_Helper; @@ -43,6 +44,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { global $wpdb; $wpdb->query( "DELETE FROM {$wpdb->prefix}wc_rate_limits" ); WC_Cache_Helper::invalidate_cache_group( WC_Rate_Limiter::CACHE_GROUP ); + Constants::clear_constants(); } /** @@ -139,8 +141,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { * @param bool $expected The expected result. */ public function test_should_current_version_be_logged( $current_version, $new_version, $transient_value, $expected ) { - $wc_version = WC()->version; - WC()->version = $current_version; + Constants::set_constant( 'WC_VERSION', $current_version ); // Set up the transient. if ( null !== $transient_value ) { @@ -156,8 +157,6 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { // Clean up. delete_site_transient( RemoteLogger::WC_NEW_VERSION_TRANSIENT ); - - WC()->version = $wc_version; } /**