Experiment: Add Rate Limits to Store API (https://github.com/woocommerce/woocommerce-blocks/pull/5962)
* Add rate limiting to cart endpoints based on session * Handle nonce and rate checks in permission_callback * Rate limit checkout only * Debug * Unused AbstractRoute * Code standards * Modify core rate limit table * Add rate limit at rest api level, not route level * Rate limit helper * Remove rate limit from routes * Usused dep * Remove custom error logic no longer needed * Remove dependency * Remove custom permission_callback * Hash IP and handle null * Remove error response handler * revert error_to_response changes * Remove add_response_headers * Remove IDENTIFIER * Remove white space * Increase limit * Missing class comment * Move rate limiting code within store api codebase * white space * Fix return type * Check rate limit expiry greater than now * Remove x- prefix * reorder functions * remove table * pass request to add_nonce_headers * return early and avoid elseif on AbstractCartRoute:get_response() * Refactor get_ip_address() before implementing options for functionality * Change rate limit to 5 requests Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com> * Change rate limit window to 60 seconds Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com> * Disable rate limiting by default Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com> * Updated limits comment * Example for Forwarded header * Updated "woocommerce_store_api_enable_rate_limit_check" filter doc * Added filter for the Store API rate limit check proxy support * Add an action here that carries over the IP address being blocked. * Added logic around setting the action_id, and returns an error when ip cannot be determined for users not logged in. * Renamed action for limit exceeded. * Common rate limiting header naming prefix, and fixed comment typos. * Doc for Rate Limiting (wip) * Example for Rate Limiting docs * Remove private IP range block for rate limiting * Refactored get_response() to add nonce headers to response instead of request * Disable batching for Checkout calls to prevent bypassing Rate Limiting. * Removed redundant arg. * package-lock.json update * Removed repeated func calls. * Fix failing tests. * Tests wip. * Request limit and timeframe are now constants for RateLimits utility class. * Tests for Rate Limit headers. * Reverted PHPUnit config to enable all tests again. * Update src/StoreApi/Authentication.php comment wording Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> * Removed possibly unnecessary get_ip_address() call. * Changed wording on comment for get_ip_address() method. * Simplified validate_ip() method. * Fixed wrong header entry for "Forwarded" check. * Unit testing for Authentication::get_ip_address() * Comment explaining the reason to use ReflectionClass for testing get_ip_address(). * Support for error output outside batch request. * MD linting. * Refactor to implement options through a single filter. * fixed md lint error and config file * reverted accidental default func arg value removal * re-enabled batch support for checkout * action for limit exceed now also triggered in case we can't resolve the IP. * Doc tweak. * Return unresolved IP address when REMOTE_ADDR isn't set with proxy support disabled. * Group unresolved ips for rate limiting * Fixed bug where current limit wasn't properly initialized. Co-authored-by: Nadir Seghir <nadir.seghir@gmail.com> Co-authored-by: Paulo Arromba <17236129+wavvves@users.noreply.github.com> Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
This commit is contained in:
parent
1884040a6a
commit
07a612f575
|
@ -188,7 +188,7 @@ export const controls = {
|
|||
if ( errorResponse.body ) {
|
||||
reject( errorResponse.body );
|
||||
} else {
|
||||
reject();
|
||||
reject( errorResponse );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"name": "@woocommerce/block-library",
|
||||
"version": "8.8.0-dev",
|
||||
"version": "8.9.0-dev",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0+",
|
||||
"dependencies": {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
namespace Automattic\WooCommerce\StoreApi;
|
||||
|
||||
use Automattic\WooCommerce\StoreApi\Utilities\RateLimits;
|
||||
|
||||
/**
|
||||
* Authentication class.
|
||||
*/
|
||||
|
@ -20,15 +22,51 @@ class Authentication {
|
|||
* @return \WP_Error|null|bool
|
||||
*/
|
||||
public function check_authentication( $result ) {
|
||||
if ( ! empty( $result ) ) {
|
||||
if ( ! $this->is_request_to_store_api() ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ( $this->is_request_to_store_api() ) {
|
||||
return true;
|
||||
$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 );
|
||||
}
|
||||
|
||||
$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-Reset', time() + $retry );
|
||||
|
||||
$ip_address = $ip_address ?? self::get_ip_address( $rate_limiting_options->proxy_support );
|
||||
do_action( 'woocommerce_store_api_rate_limit_exceeded', $ip_address );
|
||||
|
||||
return new \WP_Error(
|
||||
'rate_limit_exceeded',
|
||||
sprintf(
|
||||
'Too many requests. Please wait %d seconds before trying again.',
|
||||
$retry
|
||||
),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$rate_limit = RateLimits::update_rate_limit( $action_id );
|
||||
$server->send_header( 'RateLimit-Remaining', $rate_limit->remaining );
|
||||
$server->send_header( 'RateLimit-Reset', $rate_limit->reset );
|
||||
}
|
||||
|
||||
return $result;
|
||||
// Pass through errors from other authentication methods used before this one.
|
||||
return ! empty( $result ) ? $result : true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -55,4 +93,86 @@ class Authentication {
|
|||
}
|
||||
return 0 === strpos( $GLOBALS['wp']->query_vars['rest_route'], '/wc/store/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user IP Address.
|
||||
*
|
||||
* X_REAL_IP and CLIENT_IP are custom implementations designed to facilitate obtaining a user's ip through proxies, load balancers etc.
|
||||
*
|
||||
* _FORWARDED_FOR (XFF) request header is a de-facto standard header for identifying the originating IP address of a client connecting to a web server through a proxy server.
|
||||
* Note for X_FORWARDED_FOR, Proxy servers can send through this header like this: X-Forwarded-For: client1, proxy1, proxy2.
|
||||
* Make sure we always only send through the first IP in the list which should always be the client IP.
|
||||
* Documentation at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
|
||||
*
|
||||
* Forwarded request header contains information that may be added by reverse proxy servers (load balancers, CDNs, and so on).
|
||||
* Documentation at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
|
||||
* Full RFC at https://datatracker.ietf.org/doc/html/rfc7239
|
||||
*
|
||||
* @param boolean $proxy_support Enables/disables proxy support.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function get_ip_address( bool $proxy_support = false ) {
|
||||
|
||||
if ( ! $proxy_support ) {
|
||||
return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? 'unresolved_ip' ) ) );
|
||||
}
|
||||
|
||||
if ( array_key_exists( 'HTTP_X_REAL_IP', $_SERVER ) ) {
|
||||
return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) ) );
|
||||
}
|
||||
|
||||
if ( array_key_exists( 'HTTP_CLIENT_IP', $_SERVER ) ) {
|
||||
return self::validate_ip( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ) ) );
|
||||
}
|
||||
|
||||
if ( array_key_exists( 'HTTP_X_FORWARDED_FOR', $_SERVER ) ) {
|
||||
$ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
|
||||
if ( is_array( $ips ) && ! empty( $ips ) ) {
|
||||
return self::validate_ip( trim( $ips[0] ) );
|
||||
}
|
||||
}
|
||||
|
||||
if ( array_key_exists( 'HTTP_FORWARDED', $_SERVER ) ) {
|
||||
// Using regex instead of explode() for a smaller code footprint.
|
||||
// Expected format: Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43,for="[2001:db8:cafe::17]:4711"...
|
||||
preg_match(
|
||||
'/(?<=for\=)[^;,]*/i', // We catch everything on the first "for" entry, and validate later.
|
||||
sanitize_text_field( wp_unslash( $_SERVER['HTTP_FORWARDED'] ) ),
|
||||
$matches
|
||||
);
|
||||
|
||||
if ( strpos( $matches[0] ?? '', '"[' ) !== false ) { // Detect for ipv6, eg "[ipv6]:port".
|
||||
preg_match(
|
||||
'/(?<=\[).*(?=\])/i', // We catch only the ipv6 and overwrite $matches.
|
||||
$matches[0],
|
||||
$matches
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $matches ) ) {
|
||||
return self::validate_ip( trim( $matches[0] ) );
|
||||
}
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses filter_var() to validate and return ipv4 and ipv6 addresses
|
||||
* Will return 0.0.0.0 if the ip is not valid. This is done to group and still rate limit invalid ips.
|
||||
*
|
||||
* @param string $ip ipv4 or ipv6 ip string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function validate_ip( $ip ) {
|
||||
$ip = filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
array( FILTER_FLAG_NO_RES_RANGE, FILTER_FLAG_IPV6 )
|
||||
);
|
||||
|
||||
return $ip ?: '0.0.0.0';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,6 +69,16 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
$this->order_controller = new OrderController();
|
||||
}
|
||||
|
||||
/**
|
||||
* Are we updating data or getting data?
|
||||
*
|
||||
* @param \WP_REST_Request $request Request object.
|
||||
* @return boolean
|
||||
*/
|
||||
protected function is_update_request( \WP_REST_Request $request ) {
|
||||
return in_array( $request->get_method(), [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route response based on the type of request.
|
||||
*
|
||||
|
@ -97,8 +107,10 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
}
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$response = $this->error_to_response( $response );
|
||||
} elseif ( in_array( $request->get_method(), [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true ) ) {
|
||||
return $this->error_to_response( $response );
|
||||
}
|
||||
|
||||
if ( $this->is_update_request( $request ) ) {
|
||||
$this->cart_updated( $request );
|
||||
}
|
||||
|
||||
|
@ -192,7 +204,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
* @return bool
|
||||
*/
|
||||
protected function requires_nonce( \WP_REST_Request $request ) {
|
||||
return 'GET' !== $request->get_method();
|
||||
return $this->is_update_request( $request );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,245 @@
|
|||
<?php
|
||||
namespace Automattic\WooCommerce\StoreApi\Utilities;
|
||||
|
||||
use WC_Rate_Limiter;
|
||||
use WC_Cache_Helper;
|
||||
|
||||
/**
|
||||
* RateLimits class.
|
||||
*/
|
||||
class RateLimits extends WC_Rate_Limiter {
|
||||
|
||||
/**
|
||||
* Cache group.
|
||||
*/
|
||||
const CACHE_GROUP = 'store_api_rate_limit';
|
||||
|
||||
/**
|
||||
* Rate limiting enabled default value.
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
const ENABLED = false;
|
||||
|
||||
/**
|
||||
* Proxy support enabled default value.
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
const PROXY_SUPPORT = false;
|
||||
|
||||
/**
|
||||
* Default amount of max requests allowed for the defined timeframe.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const LIMIT = 25;
|
||||
|
||||
/**
|
||||
* Default time in seconds before rate limits are reset.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const SECONDS = 10;
|
||||
|
||||
/**
|
||||
* Gets a cache prefix.
|
||||
*
|
||||
* @param string $action_id Identifier of the action.
|
||||
* @return string
|
||||
*/
|
||||
protected static function get_cache_key( $action_id ) {
|
||||
return WC_Cache_Helper::get_cache_prefix( 'store_api_rate_limit' . $action_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit row from DB and normalize types. This query is not cached, and returns
|
||||
* 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 ) {
|
||||
global $wpdb;
|
||||
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"
|
||||
SELECT rate_limit_expiry as reset, rate_limit_remaining as remaining
|
||||
FROM {$wpdb->prefix}wc_rate_limits
|
||||
WHERE rate_limit_key = %s
|
||||
AND rate_limit_expiry > %s
|
||||
",
|
||||
$action_id,
|
||||
time()
|
||||
),
|
||||
'OBJECT'
|
||||
);
|
||||
|
||||
if ( empty( $row ) ) {
|
||||
$options = self::get_options();
|
||||
|
||||
return (object) [
|
||||
'reset' => (int) $options->seconds + time(),
|
||||
'remaining' => (int) $options->limit,
|
||||
];
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'reset' => (int) $row->reset,
|
||||
'remaining' => (int) $row->remaining,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ) {
|
||||
$current_limit = self::get_cached( $action_id );
|
||||
|
||||
if ( false === $current_limit ) {
|
||||
$current_limit = self::get_rate_limit_row( $action_id );
|
||||
self::set_cache( $action_id, $current_limit );
|
||||
}
|
||||
|
||||
return $current_limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* If exceeded, seconds until reset.
|
||||
*
|
||||
* @param string $action_id Identifier of the action.
|
||||
*
|
||||
* @return bool|int
|
||||
*/
|
||||
public static function is_exceeded_retry_after( $action_id ) {
|
||||
$current_limit = self::get_rate_limit( $action_id );
|
||||
|
||||
// Before the next run is allowed, retry forbidden.
|
||||
if ( time() <= $current_limit->reset && 0 === $current_limit->remaining ) {
|
||||
return (int) $current_limit->reset - time();
|
||||
}
|
||||
|
||||
// After the next run is allowed, retry allowed.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ) {
|
||||
global $wpdb;
|
||||
|
||||
$options = self::get_options();
|
||||
|
||||
$rate_limit_expiry = time() + $options->seconds;
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$wpdb->prefix}wc_rate_limits
|
||||
(`rate_limit_key`, `rate_limit_expiry`, `rate_limit_remaining`)
|
||||
VALUES
|
||||
(%s, %d, %d)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`rate_limit_remaining` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_remaining`), GREATEST(`rate_limit_remaining` - 1, 0)),
|
||||
`rate_limit_expiry` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_expiry`), `rate_limit_expiry`);
|
||||
",
|
||||
$action_id,
|
||||
$rate_limit_expiry,
|
||||
$options->limit - 1,
|
||||
time(),
|
||||
time()
|
||||
)
|
||||
);
|
||||
|
||||
$current_limit = self::get_rate_limit_row( $action_id );
|
||||
|
||||
self::set_cache( $action_id, $current_limit );
|
||||
|
||||
return $current_limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a cached store api rate limit.
|
||||
*
|
||||
* @param string $action_id Identifier of the action.
|
||||
* @return bool|object
|
||||
*/
|
||||
protected static function get_cached( $action_id ) {
|
||||
return wp_cache_get( self::get_cache_key( $action_id ), self::CACHE_GROUP );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a rate limit.
|
||||
*
|
||||
* @param string $action_id Identifier of the action.
|
||||
* @param object $current_limit Current limit object with expiry and retries remaining.
|
||||
* @return bool
|
||||
*/
|
||||
protected static function set_cache( $action_id, $current_limit ) {
|
||||
return wp_cache_set( self::get_cache_key( $action_id ), $current_limit, self::CACHE_GROUP );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return options for Rate Limits, to be returned by the "woocommerce_store_api_rate_limit_options" filter.
|
||||
*
|
||||
* @return object Default options.
|
||||
*/
|
||||
public static function get_options() {
|
||||
$default_options = [
|
||||
/**
|
||||
* Filters the Store API rate limit check, which is disabled by default.
|
||||
*
|
||||
* This can be used also to disable the rate limit check when testing API endpoints via a REST API client.
|
||||
*/
|
||||
'enabled' => self::ENABLED,
|
||||
|
||||
/**
|
||||
* Filters whether proxy support is enabled for the Store API rate limit check. This is disabled by default.
|
||||
*
|
||||
* If the store is behind a proxy, load balancer, CDN etc. the user can enable this to properly obtain
|
||||
* the client's IP address through standard transport headers.
|
||||
*/
|
||||
'proxy_support' => self::PROXY_SUPPORT,
|
||||
|
||||
'limit' => self::LIMIT,
|
||||
'seconds' => self::SECONDS,
|
||||
];
|
||||
|
||||
return (object) array_merge( // By using array_merge we ensure we get a properly populated options object.
|
||||
$default_options,
|
||||
/**
|
||||
* Filters options for Rate Limits.
|
||||
*
|
||||
* @param array $rate_limit_options Array of option values.
|
||||
* @return array
|
||||
*/
|
||||
apply_filters(
|
||||
'woocommerce_store_api_rate_limit_options',
|
||||
$default_options
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single option through provided name.
|
||||
*
|
||||
* @param string $option Option name.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get_option( $option ) {
|
||||
|
||||
if ( ! is_string( $option ) || ! defined( 'RateLimits::' . strtoupper( $option ) ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::get_options()[ $option ];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
# Rate Limiting for Store API endpoints <!-- omit in toc -->
|
||||
|
||||
## Table of Contents <!-- omit in toc -->
|
||||
|
||||
- [Limit information](#limit-information)
|
||||
- [Methods restricted by Rate Limiting](#methods-restricted-by-rate-limiting)
|
||||
- [Rate Limiting options filter](#rate-limiting-options-filter)
|
||||
- [Proxy standard support](#proxy-standard-support)
|
||||
- [Limit usage information observability](#limit-usage-information-observability)
|
||||
- [Response headers example](#response-headers-example)
|
||||
- [Tracking limit abuses](#tracking-limit-abuses)
|
||||
- [Custom tracking usage example](#custom-tracking-usage-example)
|
||||
|
||||
[Rate Limiting](https://github.com/woocommerce/woocommerce-blocks/pull/5962) is available for Store API endpoints. This is optional and disabled by default. It can be enabled by following [these instructions](#rate-limiting-options-filter).
|
||||
|
||||
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).
|
||||
|
||||
It also offers standard support for running behind a proxy, load balancer, etc. This also optional and disabled by default.
|
||||
|
||||
|
||||
## Limit information
|
||||
|
||||
A default maximum of 25 requests can be made within a 10-second time frame. These can be changed through an [options filter](#rate-limiting-options-filter).
|
||||
|
||||
## Methods restricted by Rate Limiting
|
||||
|
||||
`POST`, `PUT`, `PATCH`, and `DELETE`
|
||||
|
||||
## Rate Limiting options filter
|
||||
|
||||
A filter is available for setting options for rate limiting:
|
||||
|
||||
```php
|
||||
add_filter( 'woocommerce_store_api_rate_limit_options', function() {
|
||||
return [
|
||||
'enabled' => RateLimits::ENABLED, // enables/disables Rate Limiting. Default: false
|
||||
'proxy_support' => RateLimits::PROXY_SUPPORT, // enables/disables Proxy support. Default: false
|
||||
'limit' => RateLimits::LIMIT, // limit of request per timeframe. Default: 25
|
||||
'seconds' => RateLimits::SECONDS, // timeframe in seconds. Default: 10
|
||||
];
|
||||
} );
|
||||
```
|
||||
|
||||
## Proxy standard support
|
||||
|
||||
If the Store is running behind a proxy, load balancer, cache service, CDNs, etc. keying limits by IP is supported through standard IP forwarding headers, namely:
|
||||
|
||||
- `X_REAL_IP`|`CLIENT_IP` *Custom popular implementations that simplify obtaining the origin IP for the request*
|
||||
- `X_FORWARDED_FOR` *De-facto standard header for identifying the originating IP, [Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For)*
|
||||
- `X_FORWARDED` *[Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded), [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)*
|
||||
|
||||
This is disabled by default.
|
||||
|
||||
## Limit usage information observability
|
||||
|
||||
Current limit information can be observed via custom response headers:
|
||||
|
||||
- `RateLimit-Limit` *Maximum requests per time frame.*
|
||||
- `RateLimit-Remaining` *Requests available during current time frame.*
|
||||
- `RateLimit-Reset` *Unix timestamp of next time frame reset.*
|
||||
- `RateLimit-Retry-After` *Seconds until requests are unblocked again. Only shown when the limit is reached.*
|
||||
|
||||
### Response headers example
|
||||
|
||||
```http
|
||||
RateLimit-Limit: 5
|
||||
RateLimit-Remaining: 0
|
||||
RateLimit-Reset: 1654880642
|
||||
RateLimit-Retry-After: 28
|
||||
```
|
||||
|
||||
## Tracking limit abuses
|
||||
|
||||
This uses a modified wc_rate_limit table with an additional remaining column for tracking the request count in any given request window.
|
||||
A custom action `woocommerce_store_api_rate_limit_exceeded` was implemented for extendability in tracking such abuses.
|
||||
|
||||
### Custom tracking usage example
|
||||
|
||||
```php
|
||||
add_action(
|
||||
'woocommerce_store_api_rate_limit_exceeded',
|
||||
function ( $offending_ip ) { /* Custom tracking implementation */ }
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[We're hiring!](https://woocommerce.com/careers/) Come work with us!
|
||||
|
||||
🐞 Found a mistake, or have a suggestion? [Leave feedback about this document here.](https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/new?assignees=&labels=type%3A+documentation&template=--doc-feedback.md&title=Feedback%20on%20./src/StoreApi/docs/rate-limiting.md)
|
||||
|
||||
<!-- /FEEDBACK -->
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
/**
|
||||
* Rate Limits Tests
|
||||
*/
|
||||
|
||||
namespace Automattic\WooCommerce\Blocks\Tests\StoreApi;
|
||||
|
||||
use Automattic\WooCommerce\StoreApi\Authentication;
|
||||
use Automattic\WooCommerce\StoreApi\Utilities\RateLimits;
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
use Spy_REST_Server;
|
||||
use WP_REST_Server;
|
||||
use WP_Test_REST_TestCase;
|
||||
|
||||
/**
|
||||
* ControllerTests
|
||||
*/
|
||||
class RateLimitsTests extends WP_Test_REST_TestCase {
|
||||
/**
|
||||
* Setup Rest API server.
|
||||
*/
|
||||
public function setUp() {
|
||||
/** @var WP_REST_Server $wp_rest_server */
|
||||
global $wp_rest_server;
|
||||
$wp_rest_server = new Spy_REST_Server();
|
||||
|
||||
$GLOBALS['wp']->query_vars['rest_route'] = '/wc/store/cart';
|
||||
$_SERVER['REMOTE_ADDR'] = '76.45.67.179';
|
||||
|
||||
do_action( 'rest_api_init', $wp_rest_server );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that Rate limiting headers are sent and set correctly when Rate Limiting
|
||||
* main functionality is enabled.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_rate_limits_response_headers() {
|
||||
add_filter(
|
||||
'woocommerce_store_api_rate_limit_options',
|
||||
function () {
|
||||
return array( 'enabled' => true );
|
||||
}
|
||||
);
|
||||
|
||||
$rate_limiting_options = RateLimits::get_options();
|
||||
|
||||
/** @var Spy_REST_Server $spy_rest_server */
|
||||
$spy_rest_server = rest_get_server();
|
||||
$spy_rest_server->serve_request( '/wc/store/cart' );
|
||||
|
||||
$this->assertArrayHasKey( 'RateLimit-Limit', $spy_rest_server->sent_headers );
|
||||
$this->assertArrayHasKey( 'RateLimit-Remaining', $spy_rest_server->sent_headers );
|
||||
$this->assertArrayHasKey( 'RateLimit-Reset', $spy_rest_server->sent_headers );
|
||||
|
||||
$this->assertEquals( $rate_limiting_options->limit, $spy_rest_server->sent_headers['RateLimit-Limit'] );
|
||||
$this->assertTrue( $spy_rest_server->sent_headers['RateLimit-Remaining'] > 0 );
|
||||
$this->assertIsInt( $spy_rest_server->sent_headers['RateLimit-Reset'] );
|
||||
$this->assertGreaterThan( time(), $spy_rest_server->sent_headers['RateLimit-Reset'] );
|
||||
|
||||
// Exhaust the limit.
|
||||
do {
|
||||
$remaining = $spy_rest_server->sent_headers['RateLimit-Remaining'];
|
||||
|
||||
$spy_rest_server->serve_request( '/wc/store/cart' );
|
||||
|
||||
$this->assertEquals( $rate_limiting_options->limit, $spy_rest_server->sent_headers['RateLimit-Limit'] );
|
||||
$this->assertIsInt( $spy_rest_server->sent_headers['RateLimit-Reset'] );
|
||||
$this->assertGreaterThan( time(), $spy_rest_server->sent_headers['RateLimit-Reset'] );
|
||||
$this->assertEquals( $remaining - 1, $spy_rest_server->sent_headers['RateLimit-Remaining'] );
|
||||
} while ( $spy_rest_server->sent_headers['RateLimit-Remaining'] > 0 );
|
||||
|
||||
// Attempt a request after rate limit is reached.
|
||||
$spy_rest_server->serve_request( '/wc/store/cart' );
|
||||
|
||||
$body = json_decode( $spy_rest_server->sent_body );
|
||||
$this->assertEquals( JSON_ERROR_NONE, json_last_error() );
|
||||
$this->assertEquals( 400, $body->data->status );
|
||||
|
||||
$this->assertEquals( $rate_limiting_options->limit, $spy_rest_server->sent_headers['RateLimit-Limit'] );
|
||||
$this->assertIsInt( $spy_rest_server->sent_headers['RateLimit-Reset'] );
|
||||
$this->assertGreaterThan( time(), $spy_rest_server->sent_headers['RateLimit-Reset'] );
|
||||
$this->assertEquals( 0, $spy_rest_server->sent_headers['RateLimit-Remaining'] );
|
||||
$this->assertArrayHasKey( 'RateLimit-Retry-After', $spy_rest_server->sent_headers );
|
||||
$this->assertIsInt( $spy_rest_server->sent_headers['RateLimit-Retry-After'] );
|
||||
$this->assertLessThanOrEqual( $rate_limiting_options->seconds, $spy_rest_server->sent_headers['RateLimit-Retry-After'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that get_ip_address() correctly selects the $_SERVER var, parses and return the IP whether
|
||||
* behind a proxy or not.
|
||||
*
|
||||
* @return void
|
||||
* @throws ReflectionException On failing invoked protected method through reflection class.
|
||||
*/
|
||||
public function test_get_ip_address_method() {
|
||||
$_SERVER = array_merge(
|
||||
$_SERVER,
|
||||
array(
|
||||
'REMOTE_ADDR' => '76.45.67.100',
|
||||
'HTTP_X_REAL_IP' => '76.45.67.101',
|
||||
'HTTP_CLIENT_IP' => '76.45.67.102',
|
||||
'HTTP_X_FORWARDED_FOR' => '76.45.67.103,2001:db8:85a3:8d3:1319:8a2e:370:7348,150.172.238.178',
|
||||
'HTTP_FORWARDED' => 'for="[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:4711";proto=http;by=203.0.113.43,for=192.0.2.60;proto=https;by=203.0.113.43',
|
||||
)
|
||||
);
|
||||
|
||||
$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_ip_address = $authentication->getMethod( 'get_ip_address' );
|
||||
$get_ip_address->setAccessible( true );
|
||||
|
||||
$this->assertEquals( '76.45.67.100', $get_ip_address->invokeArgs( $authentication, array() ) );
|
||||
|
||||
$this->assertEquals( '76.45.67.101', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
$_SERVER['HTTP_X_REAL_IP'] = 'invalid_ip_address';
|
||||
$this->assertequals( '0.0.0.0', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
|
||||
unset( $_SERVER['REMOTE_ADDR'] );
|
||||
unset( $_SERVER['HTTP_X_REAL_IP'] );
|
||||
$this->assertEquals( '76.45.67.102', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
$_SERVER['HTTP_CLIENT_IP'] = 'invalid_ip_address';
|
||||
$this->assertequals( '0.0.0.0', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
|
||||
unset( $_SERVER['HTTP_CLIENT_IP'] );
|
||||
$this->assertEquals( '76.45.67.103', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
$_SERVER['HTTP_X_FORWARDED_FOR'] = 'invalid_ip_address,76.45.67.103';
|
||||
$this->assertequals( '0.0.0.0', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
|
||||
unset( $_SERVER['HTTP_X_FORWARDED_FOR'] );
|
||||
$this->assertEquals( '2001:0db8:85a3:0000:0000:8a2e:0370:7334', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
$_SERVER['HTTP_FORWARDED'] = 'for=invalid_ip_address;proto=https;by=203.0.113.43';
|
||||
$this->assertequals( '0.0.0.0', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
|
||||
unset( $_SERVER['HTTP_FORWARDED'] );
|
||||
$this->assertequals( '0.0.0.0', $get_ip_address->invokeArgs( $authentication, array( true ) ) );
|
||||
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue