Skip to content

Commit 055004d

Browse files
committed
Add rate limit on the built-in request password reset handler for purely local accounts to limit the annoyance / abuse potential associated with somebody triggering a huge amount of password reset request emails to another known user. Thanks to Parth Narula (ScriptJacker) for reporting a similar issue in a different vendor software context. That inspired us to check and address this issue in our own software.
git-svn-id: svn+ssh://svn.code.sf.net/p/migrid/code/trunk@6046 b75ad72c-e7d7-11dd-a971-7dbc132099af
1 parent 93caa5c commit 055004d

File tree

4 files changed

+152
-21
lines changed

4 files changed

+152
-21
lines changed

mig/shared/functionality/reqpwresetaction.py

Lines changed: 97 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# --- BEGIN_HEADER ---
55
#
66
# reqpwresetaction - handle account password reset requests and send email to user
7-
# Copyright (C) 2003-2023 The MiG Project lead by Brian Vinter
7+
# Copyright (C) 2003-2024 The MiG Project lead by Brian Vinter
88
#
99
# This file is part of MiG.
1010
#
@@ -30,16 +30,20 @@
3030
from __future__ import absolute_import
3131

3232
import os
33-
import time
3433
import tempfile
34+
import time
3535

3636
from mig.shared import returnvalues
3737
from mig.shared.base import canonical_user_with_peers, generate_https_urls, \
3838
fill_distinguished_name, cert_field_map, auth_type_description, \
3939
mask_creds, is_gdp_user
4040
from mig.shared.defaults import keyword_auto, RESET_TOKEN_TTL
4141
from mig.shared.functional import validate_input, REJECT_UNSET
42+
from mig.shared.griddaemons.https import default_max_user_hits, \
43+
default_user_abuse_hits, default_proto_abuse_hits, hit_rate_limit, \
44+
expire_rate_limit, validate_auth_attempt
4245
from mig.shared.handlers import safe_handler, get_csrf_limit
46+
from mig.shared.html import themed_styles, themed_scripts
4347
from mig.shared.init import initialize_main_variables, find_entry
4448
from mig.shared.notification import send_email
4549
from mig.shared.pwcrypto import generate_reset_token
@@ -61,7 +65,22 @@ def main(client_id, user_arguments_dict):
6165
"""Main function used by front end"""
6266

6367
(configuration, logger, output_objects, op_name) = \
64-
initialize_main_variables(client_id, op_header=False, op_menu=False)
68+
initialize_main_variables(client_id, op_header=False, op_title=False,
69+
op_menu=False)
70+
# IMPORTANT: no title in init above so we MUST call it immediately here
71+
# or basic styling will break on e.g. the check user result.
72+
styles = themed_styles(configuration)
73+
scripts = themed_scripts(configuration, logged_in=False)
74+
title_entry = {'object_type': 'title',
75+
'text': '%s reset account password request' %
76+
configuration.short_title,
77+
'skipmenu': True, 'style': styles, 'script': scripts}
78+
output_objects.append(title_entry)
79+
output_objects.append({'object_type': 'header', 'text':
80+
'%s reset account password request' %
81+
configuration.short_title
82+
})
83+
6584
defaults = signature(configuration)[1]
6685
(validate_status, accepted) = validate_input(user_arguments_dict,
6786
defaults, output_objects,
@@ -71,15 +90,20 @@ def main(client_id, user_arguments_dict):
7190
logger.warning('%s invalid input: %s' % (op_name, accepted))
7291
return (accepted, returnvalues.CLIENT_ERROR)
7392

74-
title_entry = find_entry(output_objects, 'title')
75-
title_entry['text'] = '%s reset account password request' % \
76-
configuration.short_title
77-
title_entry['skipmenu'] = True
78-
output_objects.append({'object_type': 'header', 'text':
79-
'%s reset account password request' %
80-
configuration.short_title
81-
})
82-
93+
# Seconds to delay next reset attempt after hitting rate limit
94+
delay_retry = 900
95+
scripts['init'] += '''
96+
function update_reload_counter(cnt, delay) {
97+
var remain = (delay - cnt);
98+
$("#reload_counter").html(remain.toString());
99+
if (cnt >= delay) {
100+
/* Load previous page again without re-posting last attempt */
101+
location = history.back();
102+
} else {
103+
setTimeout(function() { update_reload_counter(cnt+1, delay); }, 1000);
104+
}
105+
}
106+
'''
83107
smtp_server = configuration.smtp_server
84108

85109
cert_id = accepted['cert_id'][-1].strip()
@@ -118,8 +142,68 @@ def main(client_id, user_arguments_dict):
118142
return (output_objects, returnvalues.CLIENT_ERROR)
119143

120144
mig_user = os.environ.get('USER', 'mig')
145+
client_addr = os.environ.get('REMOTE_ADDR', None)
146+
tcp_port = int(os.environ.get('REMOTE_PORT', '0'))
121147
anon_migoid_url = configuration.migserver_https_sid_url
122148

149+
status = returnvalues.OK
150+
151+
# Rate limit password reset attempts for any cert_id from source addr
152+
# to prevent excessive requests spamming users or overloading server.
153+
# We do so no matter if cert_id matches a valid user to prevent disclosure.
154+
# Rate limit does not affect reset for another ID from same address as
155+
# that may be perfectly valid e.g. if behind a shared NAT-gateway.
156+
proto = 'https'
157+
disconnect, exceeded_rate_limit = False, False
158+
# Clean up expired entries in persistent rate limit cache
159+
expire_rate_limit(configuration, proto, fail_cache=delay_retry,
160+
expire_delay=delay_retry)
161+
if hit_rate_limit(configuration, proto, client_addr, cert_id,
162+
max_user_hits=1):
163+
exceeded_rate_limit = True
164+
# Update rate limits and write to auth log
165+
(authorized, disconnect) = validate_auth_attempt(
166+
configuration,
167+
proto,
168+
op_name,
169+
cert_id,
170+
client_addr,
171+
tcp_port,
172+
secret=None,
173+
authtype_enabled=True,
174+
auth_reset=True,
175+
exceeded_rate_limit=exceeded_rate_limit,
176+
user_abuse_hits=default_user_abuse_hits,
177+
proto_abuse_hits=default_proto_abuse_hits,
178+
max_secret_hits=1,
179+
skip_notify=True,
180+
)
181+
182+
if exceeded_rate_limit or disconnect:
183+
logger.warning('Throttle %s for %s from %s - past rate limit' %
184+
(op_name, cert_id, client_addr))
185+
# NOTE: we keep actual result in plain text for json extract
186+
output_objects.append({'object_type': 'html_form', 'text': '''
187+
<div class="vertical-spacer"></div>
188+
<div class="error leftpad errortext">
189+
'''})
190+
output_objects.append({'object_type': 'text', 'text': """
191+
Invalid input or rate limit exceeded - please wait %d seconds before retrying.
192+
""" % delay_retry
193+
})
194+
output_objects.append({'object_type': 'html_form', 'text': '''
195+
</div>
196+
<div class="vertical-spacer"></div>
197+
<div class="info leftpad">
198+
Origin will reload automatically in <span id="reload_counter">%d</span> seconds.
199+
</div>
200+
</div>
201+
''' % delay_retry})
202+
scripts['ready'] += '''
203+
setTimeout(function() { update_reload_counter(1, %d); }, 1000);
204+
''' % delay_retry
205+
return (output_objects, status)
206+
123207
search_filter = default_search()
124208
if '/' in cert_id:
125209
search_filter['distinguished_name'] = cert_id
@@ -215,4 +299,4 @@ def main(client_id, user_arguments_dict):
215299
output_objects.append(
216300
{'object_type': 'link', 'destination': 'javascript:history.back();',
217301
'class': 'genericbutton', 'text': "Back"})
218-
return (output_objects, returnvalues.OK)
302+
return (output_objects, status)

mig/shared/functionality/twofactor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
from mig.shared.defaults import twofactor_cookie_ttl, AUTH_MIG_OID, \
4949
AUTH_EXT_OID, AUTH_MIG_OIDC, AUTH_EXT_OIDC
5050
from mig.shared.functional import validate_input
51-
from mig.shared.griddaemons.openid import default_max_user_hits, \
51+
from mig.shared.griddaemons.https import default_max_user_hits, \
5252
default_user_abuse_hits, default_proto_abuse_hits, hit_rate_limit, \
5353
expire_rate_limit, validate_auth_attempt
5454
from mig.shared.init import initialize_main_variables
@@ -324,8 +324,8 @@ def main(client_id, user_arguments_dict, environ=None):
324324
)
325325

326326
if exceeded_rate_limit or disconnect:
327-
logger.warning('Throttle twofactor from %s (%s) - past rate limit'
328-
% (client_id, client_addr))
327+
logger.warning('Throttle %s from %s (%s) - past rate limit' %
328+
(op_name, client_id, client_addr))
329329
# NOTE: we keep actual result in plain text for json extract
330330
output_objects.append({'object_type': 'html_form', 'text': '''
331331
<div class="vertical-spacer"></div>

mig/shared/griddaemons/auth.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# --- BEGIN_HEADER ---
55
#
66
# auth - grid daemon auth helper functions
7-
# Copyright (C) 2010-2023 The MiG Project lead by Brian Vinter
7+
# Copyright (C) 2010-2024 The MiG Project lead by Brian Vinter
88
#
99
# This file is part of MiG.
1010
#
@@ -187,6 +187,7 @@ def validate_auth_attempt(configuration,
187187
valid_twofa=False,
188188
authtype_enabled=False,
189189
valid_auth=False,
190+
auth_reset=False,
190191
exceeded_rate_limit=False,
191192
exceeded_max_sessions=False,
192193
user_abuse_hits=default_user_abuse_hits,
@@ -228,6 +229,8 @@ def validate_auth_attempt(configuration,
228229
% valid_twofa
229230
+ "authtype_enabled: %s, valid_auth: %s\n"
230231
% (authtype_enabled, valid_auth)
232+
+ "auth_reset: %s\n"
233+
% auth_reset
231234
+ "exceeded_rate_limit: %s\n"
232235
% exceeded_rate_limit
233236
+ "exceeded_max_sessions: %s\n"
@@ -261,11 +264,11 @@ def validate_auth_attempt(configuration,
261264
and authtype in configuration.user_sftp_auth:
262265
pass
263266
elif protocol == 'sftp-subsys' \
264-
and (authtype in configuration.user_sftp_auth \
265-
or authtype in ["session"]):
267+
and (authtype in configuration.user_sftp_auth
268+
or authtype in ["session"]):
266269
pass
267270
elif protocol == 'https' \
268-
and authtype in ["twofactor"]:
271+
and authtype in ["twofactor", "reqpwresetaction"]:
269272
pass
270273
elif protocol == 'openid' \
271274
and authtype in configuration.user_openid_auth:
@@ -395,6 +398,16 @@ def validate_auth_attempt(configuration,
395398
authlog(configuration, 'WARNING', protocol, authtype,
396399
username, ip_addr, auth_msg,
397400
notify=notify, hint=mount_hint)
401+
elif authtype_enabled and auth_reset:
402+
# IMPORTANT: leave unauthorized here to enforce rate limit on resets
403+
authorized = False
404+
auth_msg = "Allow %s" % authtype
405+
log_msg = auth_msg + " for %s from %s" % (username, ip_addr)
406+
if tcp_port > 0:
407+
log_msg += ":%s" % tcp_port
408+
logger.info(log_msg)
409+
authlog(configuration, 'INFO', protocol, authtype,
410+
username, ip_addr, auth_msg, notify=notify)
398411
elif authtype_enabled and not valid_auth:
399412
auth_msg = "Failed %s" % authtype
400413
mount_hint = ''
@@ -450,7 +463,7 @@ def validate_auth_attempt(configuration,
450463
username, authorized,
451464
secret=max_secret)
452465

453-
# Check if we should log abuse messages for use by eg. fail2ban
466+
# Check if we should log abuse messages for use by e.g. fail2ban
454467

455468
if user_abuse_hits > 0 and user_hits > user_abuse_hits:
456469
auth_msg = "Abuse limit reached"

mig/shared/griddaemons/https.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/python
2+
# -*- coding: utf-8 -*-
3+
#
4+
# --- BEGIN_HEADER ---
5+
#
6+
# https - Wrapper for https web page helper import
7+
# Copyright (C) 2010-2024 The MiG Project lead by Brian Vinter
8+
#
9+
# This file is part of MiG.
10+
#
11+
# MiG is free software: you can redistribute it and/or modify
12+
# it under the terms of the GNU General Public License as published by
13+
# the Free Software Foundation; either version 2 of the License, or
14+
# (at your option) any later version.
15+
#
16+
# MiG is distributed in the hope that it will be useful,
17+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
# GNU General Public License for more details.
20+
#
21+
# You should have received a copy of the GNU General Public License
22+
# along with this program; if not, write to the Free Software
23+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
24+
#
25+
# -- END_HEADER ---
26+
#
27+
28+
"""This imports all modules needed by the https web pages"""
29+
30+
from mig.shared.griddaemons.base import default_username_validator
31+
from mig.shared.griddaemons.ratelimits import default_max_user_hits, \
32+
default_user_abuse_hits, default_proto_abuse_hits, \
33+
hit_rate_limit, expire_rate_limit
34+
from mig.shared.griddaemons.auth import validate_auth_attempt

0 commit comments

Comments
 (0)