Skip to content

Commit f88165d

Browse files
committed
Merge remote-tracking branch 'origin/master' into edge
2 parents 4df7e8f + 826b5f0 commit f88165d

File tree

9 files changed

+251
-28
lines changed

9 files changed

+251
-28
lines changed

mig/install/MiGserver-template.conf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ auto_add_oid_user = __AUTO_ADD_OID_USER__
1616
auto_add_oidc_user = __AUTO_ADD_OIDC_USER__
1717
# Auto create dedicated MiG resources from valid users
1818
#auto_add_resource = False
19+
# Apply filters to handle illegal characters e.g. in names during auto add
20+
# User ID fields to filter: full_name, organization, ...
21+
# Leave filter fields empty or unset to disable all filters and let input
22+
# validation simply reject user sign up if names contain such characters.
23+
auto_add_filter_fields =
24+
# How to handle each illegal character in the configured filter fields. The
25+
# default is to skip each such character. Other valid options include hexlify
26+
# to encode each such character with the corresponding hex codepoint.
27+
auto_add_filter_method = skip
1928
# Default account expiry unless set. Renew and web login extends by default.
2029
cert_valid_days = __CERT_VALID_DAYS__
2130
oid_valid_days = __OID_VALID_DAYS__

mig/shared/configuration.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@
4848
try:
4949
from mig.shared.defaults import CSRF_MINIMAL, CSRF_WARN, CSRF_MEDIUM, \
5050
CSRF_FULL, POLICY_NONE, POLICY_WEAK, POLICY_MEDIUM, POLICY_HIGH, \
51-
POLICY_MODERN, POLICY_CUSTOM, freeze_flavors, \
51+
POLICY_MODERN, POLICY_CUSTOM, freeze_flavors, cert_field_order, \
5252
duplicati_protocol_choices, default_css_filename, keyword_any, \
5353
cert_valid_days, oid_valid_days, generic_valid_days, keyword_all, \
5454
keyword_file, keyword_env, DEFAULT_USER_ID_FORMAT, \
55-
valid_user_id_formats, default_twofactor_auth_apps
55+
valid_user_id_formats, valid_filter_methods, default_twofactor_auth_apps
5656
from mig.shared.logger import Logger, SYSLOG_GDP
5757
from mig.shared.html import menu_items, vgrid_items
5858
from mig.shared.fileio import read_file, load_json, write_file
@@ -137,6 +137,8 @@ def fix_missing(config_file, verbose=True):
137137
'auto_add_oid_user': False,
138138
'auto_add_oidc_user': False,
139139
'auto_add_resource': False,
140+
'auto_add_filter_method': '',
141+
'auto_add_filter_fields': '',
140142
'server_fqdn': fqdn,
141143
'support_email': '',
142144
'admin_email': '%s@%s' % (user, fqdn),
@@ -668,6 +670,8 @@ class Configuration:
668670
auto_add_oid_user = False
669671
auto_add_oidc_user = False
670672
auto_add_resource = False
673+
auto_add_filter_method = ''
674+
auto_add_filter_fields = []
671675

672676
# ARC resource configuration (list)
673677
# wired-in shorthands in arcwrapper:
@@ -2521,6 +2525,18 @@ def reload_config(self, verbose, skip_log=False):
25212525
self.auto_add_resource = config.getboolean('GLOBAL',
25222526
'auto_add_resource')
25232527

2528+
# Apply requested automatic filtering of selected auto add user fields
2529+
if config.has_option('GLOBAL', 'auto_add_filter_method'):
2530+
filter_method = config.get('GLOBAL', 'auto_add_filter_method')
2531+
if filter_method not in valid_filter_methods:
2532+
filter_method = ''
2533+
self.auto_add_filter_method = filter_method
2534+
if config.has_option('GLOBAL', 'auto_add_filter_fields'):
2535+
filter_fields = config.get('GLOBAL', 'auto_add_filter_fields')
2536+
valid_filter_fields = [i[0] for i in cert_field_order]
2537+
self.auto_add_filter_fields = [i for i in filter_fields.split()
2538+
if i in valid_filter_fields]
2539+
25242540
# Allow override of account valid days from shared.defaults
25252541
if config.has_option('GLOBAL', 'cert_valid_days'):
25262542
self.cert_valid_days = config.getint('GLOBAL', 'cert_valid_days')

mig/shared/defaults.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@
110110
('email', 'emailAddress'),
111111
]
112112

113+
valid_filter_methods = ['', 'skip', 'hexlify']
114+
113115
sandbox_names = ['sandbox', 'oneclick', 'ps3live']
114116

115117
email_keyword_list = ['mail', 'email']

mig/shared/functionality/autocreate.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
from __future__ import absolute_import
4141

42+
import base64
4243
import os
4344
import time
4445

@@ -231,6 +232,66 @@ def split_comma_concat(value_list, sep=','):
231232
return result
232233

233234

235+
def lookup_filter_illegal_handler(filter_method):
236+
"""Get the illegal handler function to match filter_method. The output is
237+
directly suitable for the filter_X helpers with illegal_handler argument.
238+
"""
239+
# NOTE: the None value is a special case that means skip illegal values
240+
if filter_method in ('', 'skip'):
241+
return None
242+
elif filter_method == 'hexlify':
243+
def hex_wrap(val):
244+
"""Insert a clearly marked hex representation of val"""
245+
# NOTE: use '.X' as '.x' will typically be capitalized in use anyway
246+
return ".X%s" % base64.b16encode(val)
247+
return hex_wrap
248+
else:
249+
raise ValueError("unsupported filter_method: %r" % filter_method)
250+
251+
252+
def populate_prefilters(configuration, prefilter_map, auth_type):
253+
"""Populate the prefilter map applied to input values before anything else
254+
so that they can be used e.g. to mangle illegal values into compliance.
255+
Particularly useful for making sure we keep file system names to something
256+
we can actually safely handle.
257+
"""
258+
_logger = configuration.logger
259+
_logger.debug("populate prefilters for %s" % auth_type)
260+
# TODO: add better reversible filters like punycode or base64 on whole name
261+
filter_name = configuration.auto_add_filter_method
262+
illegal_handler = lookup_filter_illegal_handler(filter_name)
263+
_logger.debug("populate prefilters found filter illegal char handler %s" %
264+
illegal_handler)
265+
if auth_type == AUTH_OPENID_V2:
266+
if filter_name and 'full_name' in configuration.auto_add_filter_fields:
267+
def _filter_helper(x):
268+
return filter_commonname(x, illegal_handler)
269+
# NOTE: KUIT OpenID 2.0 provides full name as 'fullname'
270+
for name in ('openid.sreg.fullname', 'openid.sreg.full_name'):
271+
prefilter_map[name] = _filter_helper
272+
elif auth_type == AUTH_OPENID_CONNECT:
273+
if configuration.auto_add_filter_method and \
274+
'full_name' in configuration.auto_add_filter_fields:
275+
def _filter_helper(x):
276+
return filter_commonname(x, illegal_handler)
277+
# NOTE: WAYF provides full name as 'name'
278+
for name in ('oidc.claim.fullname', 'oidc.claim.full_name',
279+
'oidc.claim.name'):
280+
prefilter_map[name] = _filter_helper
281+
elif auth_type == AUTH_CERTIFICATE:
282+
if configuration.auto_add_filter_method and \
283+
'full_name' in configuration.auto_add_filter_fields:
284+
def _filter_helper(x):
285+
return filter_commonname(x, illegal_handler)
286+
for name in ('cert_name', ):
287+
prefilter_map[name] = _filter_helper
288+
else:
289+
raise ValueError("unsupported auth_type in populate_prefilters: %r" %
290+
auth_type)
291+
_logger.debug("populate prefilters returns: %s" % prefilter_map)
292+
return prefilter_map
293+
294+
234295
def main(client_id, user_arguments_dict, environ=None):
235296
"""Main function used by front end"""
236297

@@ -257,6 +318,8 @@ def main(client_id, user_arguments_dict, environ=None):
257318
output_objects.append({'object_type': 'error_text', 'text':
258319
'%s sign up not supported' % auth_flavor})
259320
return (output_objects, returnvalues.SYSTEM_ERROR)
321+
# NOTE: simple filters to handle unsupported chars e.g. in full name
322+
populate_prefilters(configuration, prefilter_map, auth_type)
260323
elif identity and auth_type == AUTH_OPENID_V2:
261324
if auth_flavor == AUTH_MIG_OID:
262325
base_url = configuration.migserver_https_mig_oid_url
@@ -267,9 +330,8 @@ def main(client_id, user_arguments_dict, environ=None):
267330
output_objects.append({'object_type': 'error_text', 'text':
268331
'%s sign up not supported' % auth_flavor})
269332
return (output_objects, returnvalues.SYSTEM_ERROR)
270-
for name in ('openid.sreg.cn', 'openid.sreg.fullname',
271-
'openid.sreg.full_name'):
272-
prefilter_map[name] = filter_commonname
333+
# NOTE: simple filters to handle unsupported chars e.g. in full name
334+
populate_prefilters(configuration, prefilter_map, auth_type)
273335
elif identity and auth_type == AUTH_OPENID_CONNECT:
274336
if auth_flavor == AUTH_MIG_OIDC:
275337
base_url = configuration.migserver_https_mig_oidc_url
@@ -286,6 +348,8 @@ def main(client_id, user_arguments_dict, environ=None):
286348
low_key = key.replace('OIDC_CLAIM_', 'oidc.claim.').lower()
287349
if low_key in oidc_keys:
288350
user_arguments_dict[low_key] = [environ[key]]
351+
# NOTE: simple filters to handle unsupported chars e.g. in full name
352+
populate_prefilters(configuration, prefilter_map, auth_type)
289353
else:
290354
logger.error('autocreate without ID rejected for %s' % client_id)
291355
output_objects.append({'object_type': 'error_text',

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>

0 commit comments

Comments
 (0)