diff --git a/plugins/woocommerce-blocks/assets/js/data/shared-controls.ts b/plugins/woocommerce-blocks/assets/js/data/shared-controls.ts index 2370b4b8801..6631f86b9d5 100644 --- a/plugins/woocommerce-blocks/assets/js/data/shared-controls.ts +++ b/plugins/woocommerce-blocks/assets/js/data/shared-controls.ts @@ -188,7 +188,7 @@ export const controls = { if ( errorResponse.body ) { reject( errorResponse.body ); } else { - reject(); + reject( errorResponse ); } } ); } diff --git a/plugins/woocommerce-blocks/package-lock.json b/plugins/woocommerce-blocks/package-lock.json index 5d2da14afa6..d82452623a1 100644 --- a/plugins/woocommerce-blocks/package-lock.json +++ b/plugins/woocommerce-blocks/package-lock.json @@ -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": { diff --git a/plugins/woocommerce-blocks/src/StoreApi/Authentication.php b/plugins/woocommerce-blocks/src/StoreApi/Authentication.php index bc585a8a325..1b2b69c7491 100644 --- a/plugins/woocommerce-blocks/src/StoreApi/Authentication.php +++ b/plugins/woocommerce-blocks/src/StoreApi/Authentication.php @@ -1,6 +1,8 @@ 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'; + } } diff --git a/plugins/woocommerce-blocks/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce-blocks/src/StoreApi/Routes/V1/AbstractCartRoute.php index 9fb10a3f0d5..036d420df73 100644 --- a/plugins/woocommerce-blocks/src/StoreApi/Routes/V1/AbstractCartRoute.php +++ b/plugins/woocommerce-blocks/src/StoreApi/Routes/V1/AbstractCartRoute.php @@ -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 ); } /** diff --git a/plugins/woocommerce-blocks/src/StoreApi/Utilities/RateLimits.php b/plugins/woocommerce-blocks/src/StoreApi/Utilities/RateLimits.php new file mode 100644 index 00000000000..6d791215a0e --- /dev/null +++ b/plugins/woocommerce-blocks/src/StoreApi/Utilities/RateLimits.php @@ -0,0 +1,245 @@ +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 ]; + } +} diff --git a/plugins/woocommerce-blocks/src/StoreApi/docs/rate-limiting.md b/plugins/woocommerce-blocks/src/StoreApi/docs/rate-limiting.md new file mode 100644 index 00000000000..144ab886c29 --- /dev/null +++ b/plugins/woocommerce-blocks/src/StoreApi/docs/rate-limiting.md @@ -0,0 +1,94 @@ +# Rate Limiting for Store API endpoints + +## Table of Contents + +- [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) + + diff --git a/plugins/woocommerce-blocks/tests/php/StoreApi/RateLimitsTests.php b/plugins/woocommerce-blocks/tests/php/StoreApi/RateLimitsTests.php new file mode 100644 index 00000000000..683de0597f2 --- /dev/null +++ b/plugins/woocommerce-blocks/tests/php/StoreApi/RateLimitsTests.php @@ -0,0 +1,141 @@ +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 ) ) ); + + } +}