Skip to content

Commit df55c0d

Browse files
committed
Merge branch '3078-rate-limit-login' into develop
Issue #3078 PR #3216
2 parents f5cf474 + 8d972ac commit df55c0d

File tree

11 files changed

+174
-31
lines changed

11 files changed

+174
-31
lines changed

envs/monkey_zoo/blackbox/test_blackbox.py

+19-6
Original file line numberDiff line numberDiff line change
@@ -171,24 +171,37 @@ def test_logout_invalidates_all_tokens(island):
171171
assert resp.status_code == HTTPStatus.UNAUTHORIZED
172172

173173

174-
def test_agent_otp_rate_limit(monkey_island_requests):
174+
AGENT_OTP_LOGIN_ENDPOINT = "/api/agent-otp-login"
175+
176+
177+
@pytest.mark.parametrize(
178+
"request_callback, successful_request_status, max_requests_per_second",
179+
[
180+
(lambda mir: mir.get(GET_AGENT_OTP_ENDPOINT), HTTPStatus.OK, MAX_OTP_REQUESTS_PER_SECOND),
181+
],
182+
)
183+
def test_rate_limit(
184+
monkey_island_requests, request_callback, successful_request_status, max_requests_per_second
185+
):
175186
monkey_island_requests.login()
176187
threads = []
177188
response_codes = []
178189

179-
def make_request():
180-
response = monkey_island_requests.get(GET_AGENT_OTP_ENDPOINT)
190+
def make_request(monkey_island_requests, request_callback):
191+
response = request_callback(monkey_island_requests)
181192
response_codes.append(response.status_code)
182193

183-
for _ in range(0, MAX_OTP_REQUESTS_PER_SECOND + 1):
184-
t = Thread(target=make_request, daemon=True)
194+
for _ in range(0, max_requests_per_second + 1):
195+
t = Thread(
196+
target=make_request, args=(monkey_island_requests, request_callback), daemon=True
197+
)
185198
t.start()
186199
threads.append(t)
187200

188201
for t in threads:
189202
t.join()
190203

191-
assert response_codes.count(HTTPStatus.OK) == MAX_OTP_REQUESTS_PER_SECOND
204+
assert response_codes.count(successful_request_status) == max_requests_per_second
192205
assert response_codes.count(HTTPStatus.TOO_MANY_REQUESTS) == 1
193206

194207

monkey/infection_monkey/island_api_client/http_client.py

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
IslandAPIError,
1818
IslandAPIRequestError,
1919
IslandAPIRequestFailedError,
20+
IslandAPIRequestLimitExceededError,
2021
IslandAPITimeoutError,
2122
)
2223

@@ -47,6 +48,8 @@ def decorated(*args, **kwargs):
4748
HTTPStatus.FORBIDDEN.value,
4849
]:
4950
raise IslandAPIAuthenticationError(err)
51+
if err.response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
52+
raise IslandAPIRequestLimitExceededError(err)
5053
if 400 <= err.response.status_code < 500:
5154
raise IslandAPIRequestError(err)
5255
if 500 <= err.response.status_code < 600:

monkey/infection_monkey/island_api_client/http_island_api_client.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import functools
22
import json
33
import logging
4-
from http import HTTPStatus
54
from pprint import pformat
5+
from time import sleep
66
from typing import Any, Dict, List, Sequence
77

88
import requests
@@ -110,8 +110,14 @@ def logout(self):
110110

111111
@handle_response_parsing_errors
112112
def _refresh_token(self):
113-
response = self._http_client.post("/refresh-authentication-token", {})
114-
self._update_token_from_response(response)
113+
for _ in range(6):
114+
try:
115+
response = self._http_client.post("/refresh-authentication-token", {})
116+
self._update_token_from_response(response)
117+
break
118+
except IslandAPIRequestLimitExceededError:
119+
sleep(0.5)
120+
continue
115121

116122
@handle_authentication_token_expiration
117123
def get_agent_binary(self, operating_system: OperatingSystem) -> bytes:
@@ -123,8 +129,6 @@ def get_agent_binary(self, operating_system: OperatingSystem) -> bytes:
123129
@handle_authentication_token_expiration
124130
def get_otp(self) -> str:
125131
response = self._http_client.get("/agent-otp")
126-
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
127-
raise IslandAPIRequestLimitExceededError("Too many requests to get OTP.")
128132
return response.json()["otp"]
129133

130134
@handle_response_parsing_errors

monkey/monkey_island/cc/services/authentication_service/authentication_facade.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,10 @@ def refresh_user_token(self, user: User) -> Tuple[Token, int]:
101101
:param user: The user to refresh the token for
102102
:return: The new token and the time when it will expire (in Unix time)
103103
"""
104-
self.revoke_all_tokens_for_user(user)
104+
with self._user_lock:
105+
self.revoke_all_tokens_for_user(user)
105106

106-
return Token(user.get_auth_token()), self._token_ttl_sec
107+
return Token(user.get_auth_token()), self._token_ttl_sec
107108

108109
def authorize_otp(self, otp: OTP) -> bool:
109110
# SECURITY: This method must not run concurrently, otherwise there could be TOCTOU errors,

monkey/monkey_island/cc/services/authentication_service/flask_resources/agent_otp.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ def __init__(self, otp_generator: IOTPGenerator, limiter: Limiter):
3232
# the class variable.
3333
#
3434
# TODO: The limit is currently applied per IP address. We will want to change
35-
# it to per-user once we require authentication for this endpoint.
35+
# it to per-user, per-IP once we require authentication for this endpoint.
36+
# Note that we do not want to limit to just per-user, otherwise this endpoint could be used
37+
# to enumerate users/tokens.
3638
with AgentOTP.lock:
3739
if AgentOTP.limiter is None:
3840
AgentOTP.limiter = limiter.limit(

monkey/monkey_island/cc/services/authentication_service/flask_resources/agent_otp_login.py

+32-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import string
22
from http import HTTPStatus
3+
from threading import Lock
34
from typing import Tuple
45

56
from flask import make_response, request
7+
from flask_limiter import Limiter, RateLimitExceeded
8+
from flask_limiter.util import get_remote_address
69

710
from common.common_consts.token_keys import ACCESS_TOKEN_KEY_NAME, TOKEN_TTL_KEY_NAME
811
from common.types import OTP, AgentID
@@ -13,6 +16,11 @@
1316
from ..authentication_facade import AuthenticationFacade
1417
from .utils import include_auth_token
1518

19+
# 100 requests per second is arbitrary, but is expected to be a good-enough limit. Remember that,
20+
# because of the agent's relay/tunnel capability, many requests could be funneled through the same
21+
# agent's relay, making them appear to come from the same IP.
22+
MAX_OTP_LOGIN_REQUESTS_PER_SECOND = 100
23+
1624

1725
class ArgumentParsingException(Exception):
1826
pass
@@ -26,8 +34,21 @@ class AgentOTPLogin(AbstractResource):
2634
"""
2735

2836
urls = ["/api/agent-otp-login"]
37+
lock = Lock()
38+
limiter = None
39+
40+
def __init__(self, authentication_facade: AuthenticationFacade, limiter: Limiter):
41+
# Since flask generates a new instance of this class for each request,
42+
# we need to ensure that a single instance of the limiter is used. Hence
43+
# the class variable.
44+
with AgentOTPLogin.lock:
45+
if AgentOTPLogin.limiter is None:
46+
AgentOTPLogin.limiter = limiter.limit(
47+
f"{MAX_OTP_LOGIN_REQUESTS_PER_SECOND}/second",
48+
key_func=get_remote_address,
49+
per_method=True,
50+
)
2951

30-
def __init__(self, authentication_facade: AuthenticationFacade):
3152
self._authentication_facade = authentication_facade
3253

3354
# Secured via OTP, not via authentication token.
@@ -39,6 +60,16 @@ def post(self):
3960
4061
:return: Authentication token in the response body
4162
"""
63+
if AgentOTPLogin.limiter is None:
64+
raise RuntimeError("limiter has not been initialized")
65+
66+
try:
67+
with AgentOTPLogin.limiter:
68+
return self._handle_otp_login_request()
69+
except RateLimitExceeded:
70+
return make_response("Rate limit exceeded", HTTPStatus.TOO_MANY_REQUESTS)
71+
72+
def _handle_otp_login_request(self):
4273
try:
4374
agent_id, otp = self._get_request_arguments(request.json)
4475
except ArgumentParsingException as err:

monkey/monkey_island/cc/services/authentication_service/flask_resources/login.py

+24-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import logging
22
from http import HTTPStatus
3+
from threading import Lock
34

45
from flask import Response, make_response, request
56
from flask.typing import ResponseValue
7+
from flask_limiter import Limiter, RateLimitExceeded
68
from flask_security.views import login
79

810
from monkey_island.cc.flask_utils import AbstractResource, responses
@@ -12,16 +14,27 @@
1214

1315
logger = logging.getLogger(__name__)
1416

17+
MAX_LOGIN_REQUESTS_PER_SECOND = 5
18+
1519

1620
class Login(AbstractResource):
1721
"""
1822
A resource for user authentication
1923
"""
2024

2125
urls = ["/api/login"]
26+
lock = Lock()
27+
limiter = None
2228

23-
def __init__(self, authentication_facade: AuthenticationFacade):
29+
def __init__(self, authentication_facade: AuthenticationFacade, limiter: Limiter):
2430
self._authentication_facade = authentication_facade
31+
with Login.lock:
32+
if Login.limiter is None:
33+
Login.limiter = limiter.limit(
34+
f"{MAX_LOGIN_REQUESTS_PER_SECOND}/second",
35+
key_func=lambda: "key", # Limit all requests, not just per IP
36+
per_method=True,
37+
)
2538

2639
# Can't be secured, used for login
2740
@include_auth_token
@@ -34,6 +47,16 @@ def post(self):
3447
3548
:return: Access token in the response body
3649
"""
50+
if Login.limiter is None:
51+
raise RuntimeError("limiter has not been initialized")
52+
53+
try:
54+
with Login.limiter:
55+
return self._handle_login_request()
56+
except RateLimitExceeded:
57+
return make_response("Rate limit exceeded", HTTPStatus.TOO_MANY_REQUESTS)
58+
59+
def _handle_login_request(self):
3760
try:
3861
username, password = get_username_password_from_request(request)
3962
except Exception:

monkey/monkey_island/cc/services/authentication_service/flask_resources/refresh_authentication_token.py

+33-1
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,47 @@
11
import logging
22
from http import HTTPStatus
3+
from threading import Lock
34

5+
from flask import make_response
6+
from flask_limiter import Limiter, RateLimitExceeded
7+
from flask_limiter.util import get_remote_address
48
from flask_login import current_user
59
from flask_security import auth_token_required
610

711
from common.common_consts.token_keys import ACCESS_TOKEN_KEY_NAME, TOKEN_TTL_KEY_NAME
812
from monkey_island.cc.flask_utils import AbstractResource, responses
913

1014
from ..authentication_facade import AuthenticationFacade
15+
from .agent_otp_login import MAX_OTP_LOGIN_REQUESTS_PER_SECOND
1116

1217
logger = logging.getLogger(__name__)
1318

19+
# We're assuming that whatever agents registered with the island simultaneously will more or less
20+
# request refresh tokens simultaneously.
21+
MAX_REFRESH_AUTHENTICATION_TOKEN_REQUESTS_PER_SECOND = MAX_OTP_LOGIN_REQUESTS_PER_SECOND
22+
1423

1524
class RefreshAuthenticationToken(AbstractResource):
1625
"""
1726
A resource for refreshing tokens
1827
"""
1928

2029
urls = ["/api/refresh-authentication-token"]
30+
lock = Lock()
31+
limiter = None
32+
33+
def __init__(self, authentication_facade: AuthenticationFacade, limiter: Limiter):
34+
# Since flask generates a new instance of this class for each request,
35+
# we need to ensure that a single instance of the limiter is used. Hence
36+
# the class variable.
37+
with RefreshAuthenticationToken.lock:
38+
if RefreshAuthenticationToken.limiter is None:
39+
RefreshAuthenticationToken.limiter = limiter.limit(
40+
f"{MAX_REFRESH_AUTHENTICATION_TOKEN_REQUESTS_PER_SECOND}/second",
41+
key_func=get_remote_address,
42+
per_method=True,
43+
)
2144

22-
def __init__(self, authentication_facade: AuthenticationFacade):
2345
self._authentication_facade = authentication_facade
2446

2547
@auth_token_required
@@ -29,6 +51,16 @@ def post(self):
2951
3052
:return: Response with a new token or an invalid request response
3153
"""
54+
if RefreshAuthenticationToken.limiter is None:
55+
raise RuntimeError("limiter has not been initialized")
56+
57+
try:
58+
with RefreshAuthenticationToken.limiter:
59+
return self._handle_refresh_authentication_token_request()
60+
except RateLimitExceeded:
61+
return make_response("Rate limit exceeded", HTTPStatus.TOO_MANY_REQUESTS)
62+
63+
def _handle_refresh_authentication_token_request(self):
3264
try:
3365
new_token, token_ttl_sec = self._authentication_facade.refresh_user_token(current_user)
3466
response = {

monkey/monkey_island/cc/services/authentication_service/flask_resources/register_resources.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,17 @@ def register_resources(
2222
api.add_resource(
2323
RegistrationStatus, *RegistrationStatus.urls, resource_class_args=(authentication_facade,)
2424
)
25-
api.add_resource(Login, *Login.urls, resource_class_args=(authentication_facade,))
25+
api.add_resource(Login, *Login.urls, resource_class_args=(authentication_facade, limiter))
2626
api.add_resource(Logout, *Logout.urls, resource_class_args=(authentication_facade,))
2727

2828
api.add_resource(AgentOTP, *AgentOTP.urls, resource_class_args=(otp_generator, limiter))
2929
api.add_resource(
3030
AgentOTPLogin,
3131
*AgentOTPLogin.urls,
32-
resource_class_args=(authentication_facade,),
32+
resource_class_args=(authentication_facade, limiter),
3333
)
3434
api.add_resource(
3535
RefreshAuthenticationToken,
3636
*RefreshAuthenticationToken.urls,
37-
resource_class_args=(authentication_facade,),
37+
resource_class_args=(authentication_facade, limiter),
3838
)

monkey/tests/unit_tests/infection_monkey/island_api_client/test_http_client.py

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
IslandAPIError,
1313
IslandAPIRequestError,
1414
IslandAPIRequestFailedError,
15+
IslandAPIRequestLimitExceededError,
1516
IslandAPITimeoutError,
1617
)
1718
from infection_monkey.island_api_client.http_client import RETRIES, HTTPClient
@@ -69,6 +70,7 @@ def test_http_client__unsupported_protocol(server):
6970
(401, IslandAPIAuthenticationError),
7071
(403, IslandAPIAuthenticationError),
7172
(400, IslandAPIRequestError),
73+
(429, IslandAPIRequestLimitExceededError),
7274
(501, IslandAPIRequestFailedError),
7375
],
7476
)

0 commit comments

Comments
 (0)