From d67c6d29343a19cdfcd9161b6d28f0cae19deb3b Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sun, 26 Nov 2023 00:01:00 +0100 Subject: [PATCH] API: add rate-limits by IP, add configurable rate-limit strategy --- api/api/api.py | 20 ++- api/api/services/__init__.py | 3 +- api/api/services/cache.py | 6 + api/api/services/rate_limit.py | 27 ++++ api/api/settings.py | 13 +- api/tests/conftest.py | 23 +-- api/tests/temp_env_var.py | 2 +- api/tests/test_api.py | 264 +++++++++++++++++++++------------ 8 files changed, 228 insertions(+), 130 deletions(-) create mode 100644 api/api/services/rate_limit.py diff --git a/api/api/api.py b/api/api/api.py index 840de77..9dc05bc 100644 --- a/api/api/api.py +++ b/api/api/api.py @@ -5,7 +5,7 @@ from web3 import Web3 from web3.middleware import construct_sign_and_send_raw_middleware -from .services import Token, Cache, claim_native, claim_token, captcha_verify +from .services import Token, Cache, Strategy, claim_native, claim_token, captcha_verify def is_token_enabled(address, tokens_list): @@ -31,7 +31,7 @@ def create_app(): w3 = Web3(Web3.HTTPProvider(app.config['FAUCET_RPC_URL'])) w3.middleware_onion.add(construct_sign_and_send_raw_middleware(app.config['FAUCET_PRIVATE_KEY'])) - cache = Cache(app.config['FAUCET_TIME_LIMIT_SECONDS']) + cache = Cache(app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']) # Set logger logging.basicConfig(level=logging.INFO) @@ -81,7 +81,7 @@ def ask(): if not w3.is_address(recipient): validation_errors.append('recipient: A valid recipient address must be specified') - if recipient.lower() == app.config['FAUCET_ADDRESS']: + if not recipient or recipient.lower() == app.config['FAUCET_ADDRESS']: validation_errors.append('recipient: address cant\'t be the Faucet address itself') token_address = request_data.get('tokenAddress', None) @@ -101,9 +101,17 @@ def ask(): if len(validation_errors) > 0: return jsonify(errors=validation_errors), 400 - # Check last claim - if cache.limit_by_address(recipient): - return jsonify(errors=['recipient: you have exceeded the limit for today. Try again in %s hours' % cache.ttl(hours=True)]), 429 + if app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.address.value: + # Check last claim + if cache.limit_by_address(recipient): + return jsonify(errors=['recipient: you have exceeded the limit for today. Try again in %s hours' % cache.ttl(hours=True)]), 429 + elif app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip.value: + ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + # Check last claim for the IP address + if cache.limit_by_ip(ip_address): + return jsonify(errors=['recipient: you have exceeded the limit for today. Try again in %s hours' % cache.ttl(hours=True)]), 429 + elif app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip_and_address: + raise NotImplemented amount_wei = w3.to_wei(amount, 'ether') try: diff --git a/api/api/services/__init__.py b/api/api/services/__init__.py index ebaa8f2..06370d1 100644 --- a/api/api/services/__init__.py +++ b/api/api/services/__init__.py @@ -1,4 +1,5 @@ from .token import Token from .cache import Cache from .transaction import claim_native, claim_token -from .captcha import captcha_verify \ No newline at end of file +from .captcha import captcha_verify +from .rate_limit import RateLimitStrategy, Strategy \ No newline at end of file diff --git a/api/api/services/cache.py b/api/api/services/cache.py index 0c69726..f6bab13 100644 --- a/api/api/services/cache.py +++ b/api/api/services/cache.py @@ -13,6 +13,12 @@ def limit_by_address(self, address): self.cache[address] = datetime.now() return cached + def limit_by_ip(self, ip): + cached = self.cache.get(ip, False) + if not cached: + self.cache[ip] = datetime.now() + return cached + def delete(self, attr): self.cache.pop(attr) diff --git a/api/api/services/rate_limit.py b/api/api/services/rate_limit.py new file mode 100644 index 0000000..c3be302 --- /dev/null +++ b/api/api/services/rate_limit.py @@ -0,0 +1,27 @@ +from enum import Enum + +class Strategy(Enum): + ip = 'IP' + address = 'ADDRESS' + ip_and_address = 'IP_AND_ADDRESS' + + +class RateLimitStrategy: + _strategies = set([Strategy.ip.value, Strategy.address.value, Strategy.ip_and_address.value]) + _strategy = None + _default_strategy = Strategy.address.value + + + @property + def default_strategy(self): + return self._default_strategy + + @property + def strategy(self): + return self._strategy + + @strategy.setter + def strategy(self, value): + if value not in self._strategies: + raise ValueError('Invalid strategy value', value, 'Expected one of', self._strategies) + self._strategy = value \ No newline at end of file diff --git a/api/api/settings.py b/api/api/settings.py index 4704721..d8fb8c3 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -1,5 +1,8 @@ import os import json + +from .services import RateLimitStrategy + from dotenv import load_dotenv from eth_account import Account from eth_account.signers.local import LocalAccount @@ -7,14 +10,20 @@ load_dotenv() +rate_limit_strategy = RateLimitStrategy() +rate_limit_strategy.strategy = os.getenv('FAUCET_RATE_LIMIT_STRATEGY', default=rate_limit_strategy.default_strategy) + FAUCET_RPC_URL = os.getenv("FAUCET_RPC_URL") FAUCET_PRIVATE_KEY = os.environ.get("FAUCET_PRIVATE_KEY") FAUCET_CHAIN_ID=os.getenv('FAUCET_CHAIN_ID') -FAUCET_CHAIN_NATIVE_TOKEN_SYMBOL=os.getenv('FAUCET_CHAIN_NATIVE_TOKEN_SYMBOL', 'xDAI') +FAUCET_CHAIN_NATIVE_TOKEN_SYMBOL=os.getenv('FAUCET_CHAIN_NATIVE_TOKEN_SYMBOL', default='xDAI') FAUCET_ENABLED_TOKENS=json.loads(os.getenv('FAUCET_ENABLED_TOKENS', default='[]')) FAUCET_AMOUNT=float(os.getenv('FAUCET_AMOUNT')) FAUCET_ADDRESS: LocalAccount = Account.from_key(FAUCET_PRIVATE_KEY).address -FAUCET_TIME_LIMIT_SECONDS=seconds=os.getenv('FAUCET_TIME_LIMIT_SECONDS', 86400) # 86400 = 24h +FAUCET_RATE_LIMIT_STRATEGY=rate_limit_strategy +FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS=seconds=os.getenv('FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS', 86400) # 86400 = 24h + CORS_ALLOWED_ORIGINS=os.getenv('CORS_ALLOWED_ORIGINS', '*') + CAPTCHA_VERIFY_ENDPOINT=os.getenv('CAPTCHA_VERIFY_ENDPOINT') CAPTCHA_SECRET_KEY=os.getenv('CAPTCHA_SECRET_KEY') \ No newline at end of file diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 7288893..a0f6ec6 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,22 +1 @@ -import pytest -from api import create_app -from temp_env_var import TEMP_ENV_VARS, NATIVE_TRANSFER_TX_HASH, TOKEN_TRANSFER_TX_HASH - -api_prefix = '/api/v1' - - -@pytest.fixture -def app(mocker): - # Mock values - mocker.patch('api.api.claim_native', return_value=NATIVE_TRANSFER_TX_HASH) - mocker.patch('api.api.claim_token', return_value=TOKEN_TRANSFER_TX_HASH) - # Instantiate app - app = create_app() - # Override configs - app.config.update(TEMP_ENV_VARS) - - yield app - -@pytest.fixture -def client(app): - return app.test_client() \ No newline at end of file +api_prefix = '/api/v1' \ No newline at end of file diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index e585831..3478432 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -11,7 +11,7 @@ 'FAUCET_CHAIN_ID': '100000', 'FAUCET_PRIVATE_KEY': token_bytes(32).hex(), 'FAUCET_AMOUNT': 0.1, - 'FAUCET_TIME_LIMIT_SECONDS': '1', + 'FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS': '1', 'FAUCET_ENABLED_TOKENS': json.loads('[{"address":"' + ZERO_ADDRESS + '", "name": "TestToken"}]'), 'CAPTCHA_SECRET_KEY': CAPTCHA_TEST_SECRET_KEY } diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 8a626b8..26502c0 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,103 +1,171 @@ +from api import create_app +from api.services import RateLimitStrategy, Strategy + +import pytest from conftest import api_prefix # from mock import patch from temp_env_var import TEMP_ENV_VARS, NATIVE_TRANSFER_TX_HASH, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS, CAPTCHA_TEST_RESPONSE_TOKEN -def test_status_route(app, client): - response = client.get(api_prefix + '/status') - assert response.status_code == 200 - assert response.get_json().get('status') == 'ok' - -def test_ask_route_parameters(client): - response = client.post(api_prefix + '/ask', json={}) - assert response.status_code == 400 - - # wrong chainid should return 400 - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': -1, - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], - 'recipient': ZERO_ADDRESS, - 'tokenAddress': 'native' - }) - assert response.status_code == 400 - - # wrong amount, should return 400 - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, - 'recipient': ZERO_ADDRESS, - 'tokenAddress': 'native' - }) - assert response.status_code == 400 - - # missing recipient, should return 400 - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, - 'tokenAddress': 'native' - }) - assert response.status_code == 400 - - # wrong recipient recipient, should return 400 - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, - 'recipient': 'not an address', - 'tokenAddress': 'native' - }) - assert response.status_code == 400 - - # missing token address, should return 400 - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, - 'recipient': ZERO_ADDRESS - }) - assert response.status_code == 400 - - # wrong token address, should return 400 - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, - 'recipient': ZERO_ADDRESS, - 'tokenAddress': 'non existing token address' - }) - assert response.status_code == 400 - -def test_ask_route_native_transaction(client): - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], - 'recipient': ZERO_ADDRESS, - 'tokenAddress': 'native' - }) - assert response.status_code == 200 - assert response.get_json().get('transactionHash') == NATIVE_TRANSFER_TX_HASH - -def test_ask_route_token_transaction(client): - # not supported token, should return 400 - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], - 'recipient': ZERO_ADDRESS, - 'tokenAddress': '0x' + '1'*40 - }) - assert response.status_code == 400 - - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], - 'recipient': ZERO_ADDRESS, - 'tokenAddress': TEMP_ENV_VARS['FAUCET_ENABLED_TOKENS'][0]['address'] - }) - assert response.status_code == 200 - assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH \ No newline at end of file +class BaseTest: + def _mock(self, mocker): + # Mock values + mocker.patch('api.api.claim_native', return_value=NATIVE_TRANSFER_TX_HASH) + mocker.patch('api.api.claim_token', return_value=TOKEN_TRANSFER_TX_HASH) + return mocker + + def _create_app(self, env_vars=None): + # Instantiate app + app = create_app() + if env_vars: + # Override configs + app.config.update(env_vars) + return app + + @pytest.fixture + def app(self, mocker): + mocker = self._mock(mocker) + app = self._create_app(TEMP_ENV_VARS) + yield app + + @pytest.fixture + def client(self, app): + return app.test_client() + + +class TestAPI(BaseTest): + def test_status_route(self, app, client): + response = client.get(api_prefix + '/status') + assert response.status_code == 200 + assert response.get_json().get('status') == 'ok' + + def test_ask_route_parameters(self, client): + response = client.post(api_prefix + '/ask', json={}) + assert response.status_code == 400 + + # wrong chainid should return 400 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': -1, + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'recipient': ZERO_ADDRESS, + 'tokenAddress': 'native' + }) + assert response.status_code == 400 + + # wrong amount, should return 400 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': 'native' + }) + assert response.status_code == 400 + + # missing recipient, should return 400 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'tokenAddress': 'native' + }) + assert response.status_code == 400 + + # wrong recipient recipient, should return 400 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'recipient': 'not an address', + 'tokenAddress': 'native' + }) + assert response.status_code == 400 + + # missing token address, should return 400 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'recipient': ZERO_ADDRESS + }) + assert response.status_code == 400 + + # wrong token address, should return 400 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'] + 1, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': 'non existing token address' + }) + assert response.status_code == 400 + + def test_ask_route_native_transaction(self, client): + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'recipient': ZERO_ADDRESS, + 'tokenAddress': 'native' + }) + assert response.status_code == 200 + assert response.get_json().get('transactionHash') == NATIVE_TRANSFER_TX_HASH + + def test_ask_route_token_transaction(self, client): + # not supported token, should return 400 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'recipient': ZERO_ADDRESS, + 'tokenAddress': '0x' + '1'*40 + }) + assert response.status_code == 400 + + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'recipient': ZERO_ADDRESS, + 'tokenAddress': TEMP_ENV_VARS['FAUCET_ENABLED_TOKENS'][0]['address'] + }) + assert response.status_code == 200 + assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH + + +class TestAPIWithIPLimitStrategy(BaseTest): + @pytest.fixture + def app(self, mocker): + mocker = self._mock(mocker) + + # Set rate limit strategy to IP + rate_limit_strategy = RateLimitStrategy() + rate_limit_strategy.strategy = Strategy.ip.value + + env_vars = TEMP_ENV_VARS.copy() + env_vars['FAUCET_RATE_LIMIT_STRATEGY'] = rate_limit_strategy + + app = self._create_app(env_vars) + yield app + + def test_ask_route_limit_by_ip(self, client): + # First request should return 200 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'recipient': ZERO_ADDRESS, + 'tokenAddress': TEMP_ENV_VARS['FAUCET_ENABLED_TOKENS'][0]['address'] + }) + assert response.status_code == 200 + + # Second request should return 429 + response = client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], + 'amount': TEMP_ENV_VARS['FAUCET_AMOUNT'], + 'recipient': ZERO_ADDRESS, + 'tokenAddress': TEMP_ENV_VARS['FAUCET_ENABLED_TOKENS'][0]['address'] + }) + assert response.status_code == 429 \ No newline at end of file