Create a wrapper function for all external dependency function calls for safety

This commit is contained in:
Chi-Hsuan Huang 2024-09-16 16:00:46 +08:00
parent f529d927a2
commit be81f7eabf
3 changed files with 479 additions and 66 deletions

View File

@ -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;
}

View File

@ -0,0 +1,413 @@
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Logging;
use Automattic\Jetpack\Constants;
/**
* UseNonBuiltInFunctions Trait
*
* This trait creates a wrapper for non-built-in functions for safety.
*
* @since 9.4.0
* @package Automattic\WooCommerce\Internal\Logging
*/
trait UseNonBuiltInFunctions {
/**
* Wp_parse_url wrapper.
*
* @param string $url The URL to parse.
* @param int $component The specific component to retrieve. Use one of the PHP
* predefined constants to specify which one.
* Defaults to -1 (= return all parts as an array).
* @return mixed False on parse failure; Array of URL components on success;
* When a specific component has been requested: null if the component
* doesn't exist in the given URL; a string or - in the case of
* PHP_URL_PORT - integer when it does. See parse_url()'s return values.
*/
private function wp_parse_url( $url, $component = -1 ) {
try {
if ( ! function_exists( 'wp_parse_url' ) ) {
require_once ABSPATH . WPINC . '/http.php';
}
return wp_parse_url( $url, $component );
} catch ( \Throwable $e ) {
$this->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
)
);
}
}

View File

@ -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;
}
/**