Enhancement/ Added a filter to override IP/UserID in grouping rate limited requests (#55209)

* Added filter for rate limiting request grouping identifier.

* Added Unit testing.

* Added Changelog.

* MD lint

* PHP Lint
This commit is contained in:
Paulo Arromba 2025-02-14 15:53:49 +00:00 committed by GitHub
parent 510683453a
commit 4379fd3434
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 144 additions and 36 deletions

View File

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

View File

@ -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)
---
<!-- FEEDBACK -->

View File

@ -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.

View File

@ -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.
*

View File

@ -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.

View File

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

View File

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