diff --git a/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/hooks/actions.md b/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/hooks/actions.md index 8780320f567..5548c337509 100644 --- a/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/hooks/actions.md +++ b/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/hooks/actions.md @@ -740,7 +740,7 @@ This hook gives extensions the chance to add or update metadata on the $order. T Fires when the rate limit is exceeded. ```php -do_action( 'woocommerce_store_api_rate_limit_exceeded', string $ip_address ) +do_action( 'woocommerce_store_api_rate_limit_exceeded', string $ip_address, string $action_id ) ``` ### Parameters diff --git a/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/hooks/filters.md index defa01ea3ce..df57219584f 100644 --- a/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/hooks/filters.md +++ b/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/hooks/filters.md @@ -1211,6 +1211,31 @@ apply_filters( 'woocommerce_store_api_rate_limit_options', array $rate_limit_opt - [StoreApi/Utilities/RateLimits.php](../../../../../woocommerce/src/StoreApi/Utilities/RateLimits.php) +--- + +## woocommerce_store_api_rate_limit_id + + +Filters the identifier to group requests for rate limiting. + +```php +apply_filters( 'woocommerce_store_api_rate_limit_id', string $identifier ); +``` + +### Parameters + +| Argument | Type | Description | +|-------------|--------|------------------------------------| +| $identifier | string | Passed on identifier for fallback. | + +### Returns + +`string` + +### Source + +- [StoreApi/Authentication.php](../../../../../woocommerce/src/StoreApi/Authentication.php) + --- diff --git a/plugins/woocommerce/changelog/enhancement-enable-changing-the-rate-limiting-trigger-from-ip-to-a-custom-value b/plugins/woocommerce/changelog/enhancement-enable-changing-the-rate-limiting-trigger-from-ip-to-a-custom-value new file mode 100644 index 00000000000..33c349edff5 --- /dev/null +++ b/plugins/woocommerce/changelog/enhancement-enable-changing-the-rate-limiting-trigger-from-ip-to-a-custom-value @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Added a filter to override IP/UserID in grouping rate limited requests. Custom logic can now be used to fingerprint requests, and allow more control over the rate limit. diff --git a/plugins/woocommerce/src/StoreApi/Authentication.php b/plugins/woocommerce/src/StoreApi/Authentication.php index befbb0b7461..d97a1bc9c28 100644 --- a/plugins/woocommerce/src/StoreApi/Authentication.php +++ b/plugins/woocommerce/src/StoreApi/Authentication.php @@ -187,33 +187,31 @@ class Authentication { $rate_limiting_options = RateLimits::get_options(); if ( $rate_limiting_options->enabled ) { - $action_id = 'store_api_request_'; - - if ( is_user_logged_in() ) { - $action_id .= get_current_user_id(); - } else { - $ip_address = self::get_ip_address( $rate_limiting_options->proxy_support ); - $action_id .= md5( $ip_address ); - } + $action_id = 'store_api_request_' . self::get_rate_limiting_id( $rate_limiting_options->proxy_support ); $retry = RateLimits::is_exceeded_retry_after( $action_id ); $server = rest_get_server(); $server->send_header( 'RateLimit-Limit', $rate_limiting_options->limit ); if ( false !== $retry ) { - $server->send_header( 'RateLimit-Retry-After', $retry ); $server->send_header( 'RateLimit-Remaining', 0 ); + $server->send_header( 'RateLimit-Retry-After', $retry ); $server->send_header( 'RateLimit-Reset', time() + $retry ); - $ip_address = $ip_address ?? self::get_ip_address( $rate_limiting_options->proxy_support ); /** * Fires when the rate limit is exceeded. * - * @since 8.9.0 - * * @param string $ip_address The IP address of the request. + * @param string $action_id The grouping identifier to the request. + * + * @since 8.9.0 + * @since 9.8.0 Added $action_id parameter. */ - do_action( 'woocommerce_store_api_rate_limit_exceeded', $ip_address ); + do_action( + 'woocommerce_store_api_rate_limit_exceeded', + self::get_ip_address( $rate_limiting_options->proxy_support ), + $action_id + ); return new \WP_Error( 'rate_limit_exceeded', @@ -233,6 +231,33 @@ class Authentication { return $result; } + /** + * Generates the request grouping identifier for the rate limiting. + * + * @param bool $proxy_support Rate Limiting proxy support. + * + * @return string + */ + protected static function get_rate_limiting_id( bool $proxy_support ): string { + + if ( is_user_logged_in() ) { + $id = (string) get_current_user_id(); + } else { + $id = md5( self::get_ip_address( $proxy_support ) ); + } + + /** + * Filters the rate limiting identifier. + * + * @param string $id The rate limiting identifier. + * + * @since 9.8.0 + */ + $id = apply_filters( 'woocommerce_store_api_rate_limit_id', $id ); + + return sanitize_key( $id ); + } + /** * Check if is request to the Store API. * diff --git a/plugins/woocommerce/src/StoreApi/Utilities/RateLimits.php b/plugins/woocommerce/src/StoreApi/Utilities/RateLimits.php index 27308e1a428..1dc398d8fdd 100644 --- a/plugins/woocommerce/src/StoreApi/Utilities/RateLimits.php +++ b/plugins/woocommerce/src/StoreApi/Utilities/RateLimits.php @@ -48,7 +48,7 @@ class RateLimits extends WC_Rate_Limiter { * @param string $action_id Identifier of the action. * @return string */ - protected static function get_cache_key( $action_id ) { + protected static function get_cache_key( $action_id ): string { return WC_Cache_Helper::get_cache_prefix( 'store_api_rate_limit' . $action_id ); } @@ -57,11 +57,14 @@ class RateLimits extends WC_Rate_Limiter { * a new rate limit row if none exists. * * @param string $action_id Identifier of the action. + * * @return object Object containing reset and remaining. */ - protected static function get_rate_limit_row( $action_id ) { + protected static function get_rate_limit_row( string $action_id ): object { global $wpdb; + $time = time(); + $row = $wpdb->get_row( $wpdb->prepare( " @@ -71,7 +74,7 @@ class RateLimits extends WC_Rate_Limiter { AND rate_limit_expiry > %s ", $action_id, - time() + $time ), 'OBJECT' ); @@ -80,7 +83,7 @@ class RateLimits extends WC_Rate_Limiter { $options = self::get_options(); return (object) [ - 'reset' => (int) $options->seconds + time(), + 'reset' => (int) $options->seconds + $time, 'remaining' => (int) $options->limit, ]; } @@ -95,9 +98,10 @@ class RateLimits extends WC_Rate_Limiter { * Returns current rate limit values using cache where possible. * * @param string $action_id Identifier of the action. + * * @return object */ - public static function get_rate_limit( $action_id ) { + public static function get_rate_limit( string $action_id ): object { $current_limit = self::get_cached( $action_id ); if ( false === $current_limit ) { @@ -115,12 +119,12 @@ class RateLimits extends WC_Rate_Limiter { * * @return bool|int */ - public static function is_exceeded_retry_after( $action_id ) { + public static function is_exceeded_retry_after( string $action_id ) { $current_limit = self::get_rate_limit( $action_id ); - + $time = time(); // Before the next run is allowed, retry forbidden. - if ( time() <= $current_limit->reset && 0 === $current_limit->remaining ) { - return (int) $current_limit->reset - time(); + if ( $time <= (int) $current_limit->reset && 0 === (int) $current_limit->remaining ) { + return (int) $current_limit->reset - $time; } // After the next run is allowed, retry allowed. @@ -131,14 +135,15 @@ class RateLimits extends WC_Rate_Limiter { * Sets the rate limit delay in seconds for action with identifier $id. * * @param string $action_id Identifier of the action. + * * @return object Current rate limits. */ - public static function update_rate_limit( $action_id ) { + public static function update_rate_limit( string $action_id ): object { global $wpdb; - $options = self::get_options(); - - $rate_limit_expiry = time() + $options->seconds; + $options = self::get_options(); + $time = time(); + $rate_limit_expiry = $time + (int) $options->seconds; $wpdb->query( $wpdb->prepare( @@ -152,9 +157,9 @@ class RateLimits extends WC_Rate_Limiter { ", $action_id, $rate_limit_expiry, - $options->limit - 1, - time(), - time() + (int) $options->limit - 1, + $time, + $time ) ); @@ -169,7 +174,7 @@ class RateLimits extends WC_Rate_Limiter { * Retrieve a cached store api rate limit. * * @param string $action_id Identifier of the action. - * @return bool|object + * @return false|object */ protected static function get_cached( $action_id ) { return wp_cache_get( self::get_cache_key( $action_id ), self::CACHE_GROUP ); @@ -182,7 +187,7 @@ class RateLimits extends WC_Rate_Limiter { * @param object $current_limit Current limit object with expiry and retries remaining. * @return bool */ - protected static function set_cache( $action_id, $current_limit ) { + protected static function set_cache( $action_id, $current_limit ): bool { return wp_cache_set( self::get_cache_key( $action_id ), $current_limit, self::CACHE_GROUP ); } @@ -191,7 +196,7 @@ class RateLimits extends WC_Rate_Limiter { * * @return object Default options. */ - public static function get_options() { + public static function get_options(): object { $default_options = [ /** * Filters the Store API rate limit check, which is disabled by default. diff --git a/plugins/woocommerce/src/StoreApi/docs/rate-limiting.md b/plugins/woocommerce/src/StoreApi/docs/rate-limiting.md index 102475ec092..37d38b621f4 100644 --- a/plugins/woocommerce/src/StoreApi/docs/rate-limiting.md +++ b/plugins/woocommerce/src/StoreApi/docs/rate-limiting.md @@ -15,7 +15,7 @@ The main purpose prevent abuse on endpoints from excessive calls and performance degradation on the machine running the store. -Rate limit tracking is controlled by either `USER ID` (logged in) or `IP ADDRESS` (unauthenticated requests). +Rate limit tracking is controlled by either `USER ID` (logged in), `IP ADDRESS` (unauthenticated requests) or filter defined logic to fingerprint and group requests. It also offers standard support for running behind a proxy, load balancer, etc. This also optional and disabled by default. @@ -27,7 +27,7 @@ Currently, this feature is only controlled via the `woocommerce_store_api_rate_l You can enable rate limiting for Checkout place order and `POST /checkout` endpoint only via the UI by going to WooCommerce -> Settings -> Advanced -> Features and enabling "Rate limiting Checkout block and Store API". -When enabled, the rate limiting will be applied to the `POST /checkout` and Place Order flow for Checkout block. The limit will be a maximum of 3 requests per 60 seconds. +When enabled via the UI, the rate limiting will only be applied to the `POST /checkout` and Place Order flow for Checkout block. The limit will be a maximum of 3 requests per 60 seconds. ## Limit information @@ -62,6 +62,21 @@ If the Store is running behind a proxy, load balancer, cache service, CDNs, etc. This is disabled by default. +## Enable Rate Limit by request custom fingerprinting + +For more advanced use cases, you can enable rate limiting by custom fingerprinting. +This allows for a custom implementation to group requests without relying on logged-in User ID or IP Address. + +### Custom basic example for grouping requests by User-Agent and Accept-Language combination + +```php +add_filter( 'woocommerce_store_api_rate_limit_id', function() { + $accept_language = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : ''; + + return md5( wc_get_user_agent() . $accept_language ); +} ); +``` + ## Limit usage information observability Current limit information can be observed via custom response headers: @@ -90,7 +105,7 @@ A custom action `woocommerce_store_api_rate_limit_exceeded` was implemented for ```php add_action( 'woocommerce_store_api_rate_limit_exceeded', - function ( $offending_ip ) { /* Custom tracking implementation */ } + function ( $offending_ip, $action_id ) { /* Custom tracking implementation */ } ); ``` diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/RateLimitsTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/RateLimitsTests.php index 121f514713a..349a600dfa8 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/RateLimitsTests.php +++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/RateLimitsTests.php @@ -138,6 +138,40 @@ class RateLimitsTests extends WP_Test_REST_TestCase { unset( $_SERVER['HTTP_FORWARDED'] ); $this->assertequals( '0.0.0.0', $get_ip_address->invokeArgs( $authentication, array( true ) ) ); + } + /** + * Tests that get_rate_limiting_id() correctly returns the USER ID, IP or filter result for set conditions. + * + * @return void + * @throws ReflectionException On failing invoked protected method through reflection class. + */ + public function test_get_rate_limiting_id_method() { + $authentication = new ReflectionClass( Authentication::class ); + // As the method we're testing is protected, we're using ReflectionClass to set it accessible from the outside. + $get_rate_limiting_id = $authentication->getMethod( 'get_rate_limiting_id' ); + $get_rate_limiting_id->setAccessible( true ); + + $_SERVER['REMOTE_ADDR'] = '76.45.67.102'; + $this->assertEquals( md5( '76.45.67.102' ), $get_rate_limiting_id->invokeArgs( $authentication, array( false ) ) ); + + $user_id = $this->factory->user->create( [ 'role' => 'customer' ] ); + wp_set_current_user( $user_id ); + $this->assertEquals( $user_id, $get_rate_limiting_id->invokeArgs( $authentication, array( false ) ) ); + + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'en-US,en;q=0.9'; + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'; + + add_filter( + 'woocommerce_store_api_rate_limit_id', + function () { + return wc_get_user_agent() . $_SERVER['HTTP_ACCEPT_LANGUAGE']; // @codingStandardsIgnoreLine + } + ); + + $this->assertEquals( + sanitize_key( wc_get_user_agent() . $_SERVER['HTTP_ACCEPT_LANGUAGE'] ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated + $get_rate_limiting_id->invokeArgs( $authentication, array( false ) ) + ); } }