mirror of
https://github.com/woocommerce/woocommerce.git
synced 2025-02-24 03:11:09 +08:00
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:
parent
510683453a
commit
4379fd3434
@ -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
|
||||
|
@ -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 -->
|
||||
|
||||
|
@ -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.
|
@ -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.
|
||||
*
|
||||
|
@ -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.
|
||||
|
@ -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 */ }
|
||||
);
|
||||
```
|
||||
|
||||
|
@ -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 ) )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user