Skip to content

Commit 21aae94

Browse files
committed
fix(api): - issue2551063 - Rest/Xmlrpc interfaces needs failed login protection.
Failed API login rate limiting with expiring lockout added.
1 parent 6559d55 commit 21aae94

File tree

12 files changed

+552
-96
lines changed

12 files changed

+552
-96
lines changed

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ python 3.6 or newer (3.4/3.5 might work, but they are not tested).
1616

1717
Fixed:
1818

19+
- issue2551063 - Rest/Xmlrpc interfaces needs failed login protection.
20+
Failed API login rate limiting with expiring lockout added. (John
21+
Rouillard)
22+
1923
Features:
2024

2125
- issue2551103 - add pragma 'display_protected' to roundup-admin. If

doc/rest.txt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,39 @@ that is not hosted at the same origin as Roundup, you must permit
7777
the origin using the ``allowed_api_origins`` setting in
7878
``config.ini``.
7979

80+
Rate Limiting API Failed Logins
81+
-------------------------------
82+
83+
To make brute force password guessing harder, the REST API has an
84+
invalid login rate limiter. It rate limits the number of failed login
85+
attempts with an invalid user or password. Valid login attempts are
86+
managed by the normal API rate limiter. The rate limiter is a GCRA
87+
leaky bucket variant. All APIs (REST/XMLRPC) share the same rate
88+
limiter. The rate limiter for the HTML/web interface is not shared by
89+
the API failed login rate limiter.
90+
91+
It is configured by settings in config.ini. Setting
92+
``api_failed_login_limit`` to a non-zero value enabled the limiter.
93+
Setting it to 0 disables the limiter (not suggested). If a user fails
94+
to log in more than ``api_failed_login_limit`` times in
95+
``api_failed_login_interval_in_sec`` seconds, a 429 HTTP error will be
96+
returned. The error also tell the user how long they must wait to try
97+
to log in again.
98+
99+
When a 429 error is returned, the account is locked until enough time
100+
has passed
101+
(``api_failed_login_interval_in_sec/api_failed_login_limit`` seconds)
102+
to make one additional login token available. Any attempt to log in
103+
while it is locked will fail. This is true even if a the correct
104+
password is supplied for a locked account. This means a brute force
105+
attempt can't try more than one password every
106+
``api_failed_login_interval_in_sec/api_failed_login_limit`` seconds on
107+
average.
108+
109+
The default values allow up to 4 attempts to login before delaying the
110+
user by 2.5 minutes (150 seconds). At this time there is no supported
111+
method to reset the rate limiter.
112+
80113
Rate Limiting the API
81114
---------------------
82115

doc/upgrading.txt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,42 @@ Contents:
9292

9393
.. index:: Upgrading; 2.2.0 to 2.3.0
9494

95+
Migrating from 2.3.0 to 2.4.0
96+
=============================
97+
98+
Update your ``config.ini`` (required)
99+
-------------------------------------
100+
101+
Upgrade tracker's config.ini file. Use::
102+
103+
roundup-admin -i /path/to/tracker updateconfig newconfig.ini
104+
105+
to generate a new ini file preserving all your settings.
106+
You can then merge any local comments from the tracker's
107+
``config.ini`` to ``newconfig.ini`` and replace
108+
``config.ini`` with ``newconfig.ini``.
109+
110+
``updateconfig`` will tell you if it is changing old default
111+
values or if a value must be changed manually.
112+
113+
This will insert the bad API login rate limiting settings.
114+
115+
Bad Login Rate Limiting and Locking (info)
116+
------------------------------------------
117+
118+
Brute force logins have been rate limited in the HTML web interface
119+
for a while. This was not the case with the API interfaces.
120+
121+
This release introduces rate limiting for invalid REST or XMLRPC API
122+
logins. As with the web interface, users who have hit the rate limit
123+
have their accounts locked until after the recommended delay time has
124+
passed. See `information on configuring the API rate limits`_ for
125+
details.
126+
127+
.. _`information on configuring the API rate limits`: rest.html#rate-limiting-api-failed-logins
128+
129+
.. index:: Upgrading; 2.2.0 to 2.3.0
130+
95131
Migrating from 2.2.0 to 2.3.0
96132
=============================
97133

doc/xmlrpc.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ issues. Patches with tests to roundup to use defusedxml are welcome.
8888
be passed in cleartext unless the server is proxied behind
8989
another server (such as Apache or lighttpd) that provides SSL.
9090

91+
Rate Limiting Failed Logins
92+
---------------------------
93+
94+
See the `rest documentation
95+
<rest.html#rate-limiting-api-failed-logins>`_ for rate limiting failed
96+
logins on the API. The XML-RPC uses the same method as the REST API.
97+
Rate limiting is shared between the XMLRPC and REST APIs.
98+
9199
Client API
92100
==========
93101
The server currently implements seven methods/commands. Each method

roundup/cgi/actions.py

Lines changed: 173 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from roundup.anypy.strings import StringIO
1313
from roundup.cgi import exceptions, templating
1414
from roundup.cgi.timestamp import Timestamped
15-
from roundup.exceptions import Reject, RejectRaw
15+
from roundup.exceptions import RateLimitExceeded, Reject, RejectRaw
1616
from roundup.i18n import _
1717
from roundup.mailgw import uidFromAddress
1818
from roundup.rate_limit import Gcra, RateLimit
@@ -26,6 +26,8 @@
2626

2727

2828
class Action:
29+
loginLimit = None
30+
2931
def __init__(self, client):
3032
self.client = client
3133
self.form = client.form
@@ -37,8 +39,6 @@ def __init__(self, client):
3739
self.base = client.base
3840
self.user = client.user
3941
self.context = templating.context(client)
40-
self.loginLimit = RateLimit(client.db.config.WEB_LOGIN_ATTEMPTS_MIN,
41-
timedelta(seconds=60))
4242

4343
def handle(self):
4444
"""Action handler procedure"""
@@ -149,7 +149,7 @@ def examine_url(self, url):
149149
if not allowed_pattern.match(parsed_url_tuple.fragment):
150150
raise ValueError(self._("Fragment component (%(url_fragment)s) in %(url)s is not properly escaped") % info)
151151

152-
return(urllib_.urlunparse(parsed_url_tuple))
152+
return urllib_.urlunparse(parsed_url_tuple)
153153

154154
name = ''
155155
permissionType = None
@@ -1298,36 +1298,6 @@ def handle(self):
12981298
redirect_url_tuple.fragment))
12991299

13001300
try:
1301-
# Implement rate limiting of logins by login name.
1302-
# Use prefix to prevent key collisions maybe??
1303-
# set client.db.config.WEB_LOGIN_ATTEMPTS_MIN to 0
1304-
# to disable
1305-
if self.client.db.config.WEB_LOGIN_ATTEMPTS_MIN: # if 0 - off
1306-
rlkey = "LOGIN-" + self.client.user
1307-
limit = self.loginLimit
1308-
gcra = Gcra()
1309-
otk = self.client.db.Otk
1310-
try:
1311-
val = otk.getall(rlkey)
1312-
gcra.set_tat_as_string(rlkey, val['tat'])
1313-
except KeyError:
1314-
# ignore if tat not set, it's 1970-1-1 by default.
1315-
pass
1316-
# see if rate limit exceeded and we need to reject the attempt
1317-
reject = gcra.update(rlkey, limit)
1318-
1319-
# Calculate a timestamp that will make OTK expire the
1320-
# unused entry 1 hour in the future
1321-
ts = otk.lifetime(3600)
1322-
otk.set(rlkey, tat=gcra.get_tat_as_string(rlkey),
1323-
__timestamp=ts)
1324-
otk.commit()
1325-
1326-
if reject:
1327-
# User exceeded limits: find out how long to wait
1328-
status = gcra.status(rlkey, limit)
1329-
raise Reject(_("Logins occurring too fast. Please wait: %s seconds.") % status['Retry-After'])
1330-
13311301
self.verifyLogin(self.client.user, password)
13321302
except exceptions.LoginError as err:
13331303
self.client.make_user_anonymous()
@@ -1347,6 +1317,28 @@ def handle(self):
13471317
raise exceptions.Redirect(redirect_url)
13481318
# if no __came_from, send back to base url with error
13491319
return
1320+
except RateLimitExceeded as err:
1321+
self.client.make_user_anonymous()
1322+
for arg in err.args:
1323+
self.client.add_error_message(arg)
1324+
1325+
if '__came_from' in self.form:
1326+
# usually web only. If API uses this they will get
1327+
# confused as the 429 isn't returned. Without this
1328+
# a web user will get redirected back to the home
1329+
# page rather than stay on the page where they tried
1330+
# to login.
1331+
# set a new error message
1332+
query['@error_message'] = err.args
1333+
redirect_url = urllib_.urlunparse(
1334+
(redirect_url_tuple.scheme,
1335+
redirect_url_tuple.netloc,
1336+
redirect_url_tuple.path,
1337+
redirect_url_tuple.params,
1338+
urllib_.urlencode(list(sorted(query.items())), doseq=True),
1339+
redirect_url_tuple.fragment))
1340+
raise exceptions.Redirect(redirect_url)
1341+
raise
13501342

13511343
# now we're OK, re-open the database for real, using the user
13521344
self.client.opendb(self.client.user)
@@ -1370,7 +1362,139 @@ def handle(self):
13701362

13711363
raise exceptions.Redirect(redirect_url)
13721364

1373-
def verifyLogin(self, username, password):
1365+
def rateLimitLogin(self, username, is_api=False, update=True):
1366+
"""Implement rate limiting of logins by login name.
1367+
1368+
username - username attempting to log in. May or may not
1369+
be valid.
1370+
is_api - set to False for login via html page
1371+
set to "xmlrpc" for xmlrpc api
1372+
set to "rest" for rest api
1373+
update - if False will raise RateLimitExceeded without
1374+
updating the stored value. Default is True
1375+
which updates stored value. Used to deny access
1376+
when user successfully logs in but the user
1377+
doesn't have a valid attempt available.
1378+
1379+
Login rates for a user are split based on html vs api
1380+
login.
1381+
1382+
Set self.client.db.config.WEB_LOGIN_ATTEMPTS_MIN to 0
1383+
to disable for web interface. Set
1384+
self.client.db.config.API_LOGIN_ATTEMPTS to 0
1385+
to disable for web interface.
1386+
1387+
By setting LoginAction.limitLogin, the admin can override
1388+
the HTML web page rate limiter if they need to change the
1389+
interval from 1 minute.
1390+
"""
1391+
config = self.client.db.config
1392+
1393+
if not is_api:
1394+
# HTML web login. Period is fixed at 1 minute.
1395+
# Override by setting self.loginLimit. Yech.
1396+
allowed_attempts = config.WEB_LOGIN_ATTEMPTS_MIN
1397+
per_period = 60
1398+
rlkey = "LOGIN-" + username
1399+
else:
1400+
# api login. Both Rest and XMLRPC use this.
1401+
allowed_attempts = config.WEB_API_FAILED_LOGIN_LIMIT
1402+
per_period = config.WEB_API_FAILED_LOGIN_INTERVAL_IN_SEC
1403+
rlkey = "LOGIN-API" + username
1404+
1405+
if not allowed_attempts: # if allowed_attempt == 0 - off
1406+
return
1407+
1408+
if self.loginLimit and not is_api:
1409+
# provide a way for user (via interfaces.py) to
1410+
# change the interval on the html login limit.
1411+
limit = self.loginLimit
1412+
else:
1413+
limit = RateLimit(allowed_attempts,
1414+
timedelta(seconds=per_period))
1415+
1416+
gcra = Gcra()
1417+
otk = self.client.db.Otk
1418+
1419+
try:
1420+
val = otk.getall(rlkey)
1421+
gcra.set_tat_as_string(rlkey, val['tat'])
1422+
except KeyError:
1423+
# ignore if tat not set, tat is 1970-1-1 by default.
1424+
pass
1425+
1426+
# see if rate limit exceeded and we need to reject
1427+
# the attempt
1428+
reject = gcra.update(rlkey, limit)
1429+
1430+
# Calculate a timestamp that will make OTK expire the
1431+
# unused entry in twice the period defined for the
1432+
# rate limiter.
1433+
if update:
1434+
ts = otk.lifetime(per_period*2)
1435+
otk.set(rlkey, tat=gcra.get_tat_as_string(rlkey),
1436+
__timestamp=ts)
1437+
otk.commit()
1438+
1439+
# User exceeded limits: find out how long to wait
1440+
limitStatus = gcra.status(rlkey, limit)
1441+
1442+
if not reject:
1443+
return
1444+
1445+
for header, value in limitStatus.items():
1446+
self.client.setHeader(header, value)
1447+
1448+
# User exceeded limits: tell humans how long to wait
1449+
# Headers above will do the right thing for api
1450+
# aware clients.
1451+
try:
1452+
retry_after = limitStatus['Retry-After']
1453+
except KeyError:
1454+
# handle race condition. If the time between
1455+
# the call to grca.update and grca.status
1456+
# is sufficient to reload the bucket by 1
1457+
# item, Retry-After will be missing from
1458+
# limitStatus. So report a 1 second delay back
1459+
# to the client. We treat update as sole
1460+
# source of truth for exceeded rate limits.
1461+
retry_after = 1
1462+
self.client.setHeader('Retry-After', '1')
1463+
1464+
# make rate limiting headers available to javascript
1465+
# even for non-api calls.
1466+
self.client.setHeader(
1467+
"Access-Control-Expose-Headers",
1468+
", ".join([
1469+
"X-RateLimit-Limit",
1470+
"X-RateLimit-Remaining",
1471+
"X-RateLimit-Reset",
1472+
"X-RateLimit-Limit-Period",
1473+
"Retry-After"
1474+
])
1475+
)
1476+
1477+
raise RateLimitExceeded(_("Logins occurring too fast. Please wait: %s seconds.") % retry_after)
1478+
1479+
def verifyLogin(self, username, password, is_api=False):
1480+
"""Authenticate the user with rate limits.
1481+
1482+
All logins (valid and failing) from a web page calling the
1483+
LoginAction method are rate limited to the config.ini
1484+
configured value in 1 minute. (Interval can be changed see
1485+
rateLimitLogin method.)
1486+
1487+
API logins are only rate limited if they fail. Successful
1488+
api logins are rate limited using the
1489+
api_calls_per_interval and api_interval_in_sec settings in
1490+
config.ini.
1491+
1492+
Once a user receives a rate limit notice, they must
1493+
wait the recommended time to try again as the account is
1494+
locked out for the recommended time. If a user tries to
1495+
log in while locked out, they will get a 429 rejection
1496+
even if the username and password are correct.
1497+
"""
13741498
# make sure the user exists
13751499
try:
13761500
# Note: lookup only searches non-retired items.
@@ -1381,10 +1505,24 @@ def verifyLogin(self, username, password):
13811505
# delay caused by checking password only on valid
13821506
# users.
13831507
_discard = self.verifyPassword("2", password) # noqa: F841
1508+
1509+
# limit logins to an acceptable rate. Do it for
1510+
# invalid usernames so attempts to break
1511+
# an invalid user will also be rate limited.
1512+
self.rateLimitLogin(username, is_api=is_api)
1513+
1514+
# this is not hit if rate limit is exceeded
13841515
raise exceptions.LoginError(self._('Invalid login'))
13851516

1517+
# if we are rate limited and the user tries again,
1518+
# reject the login. update=false so we don't count
1519+
# a potentially valid login.
1520+
self.rateLimitLogin(username, is_api=is_api, update=False)
1521+
13861522
# verify the password
13871523
if not self.verifyPassword(self.client.userid, password):
1524+
self.rateLimitLogin(username, is_api=is_api)
1525+
# this is not hit if rate limit is exceeded
13881526
raise exceptions.LoginError(self._('Invalid login'))
13891527

13901528
# Determine whether the user has permission to log in.

0 commit comments

Comments
 (0)