diff --git a/axes/conf.py b/axes/conf.py index 9e8e41f..d2ffe65 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -123,7 +123,5 @@ settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGIN settings.AXES_SENSITIVE_PARAMETERS = getattr( settings, "AXES_SENSITIVE_PARAMETERS", - [ - "password", - ], + [], ) diff --git a/axes/handlers/database.py b/axes/handlers/database.py index 8cc9a61..187a089 100644 --- a/axes/handlers/database.py +++ b/axes/handlers/database.py @@ -17,7 +17,6 @@ from axes.helpers import ( get_credentials, get_failure_limit, get_query_str, - cleanse_params, ) from axes.models import AccessLog, AccessAttempt from axes.signals import user_locked_out @@ -110,8 +109,8 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): ) # This replaces null byte chars that crash saving failures, meaning an attacker doesn't get locked out. - get_data = get_query_str(cleanse_params(request.GET)).replace("\0", "0x00") - post_data = get_query_str(cleanse_params(request.POST)).replace("\0", "0x00") + get_data = get_query_str(request.GET).replace("\0", "0x00") + post_data = get_query_str(request.POST).replace("\0", "0x00") if self.is_whitelisted(request, credentials): log.info("AXES: Login failed from whitelisted client %s.", client_str) diff --git a/axes/helpers.py b/axes/helpers.py index 920a40c..d92a80e 100644 --- a/axes/helpers.py +++ b/axes/helpers.py @@ -277,18 +277,42 @@ def get_client_str( return client_str +def cleanse_parameters(params: dict) -> dict: + """ + Replace sensitive parameter values in a parameter dict with + a safe placeholder value. + + Parameters name ``'password'`` will always be cleansed. Additionally, + parameters named in ``settings.AXES_SENSITIVE_PARAMETERS`` and + ``settings.AXES_PASSWORD_FORM_FIELD will be cleansed. + + This is used to prevent passwords and similar values from + being logged in cleartext. + """ + sensitive_parameters = ["password"] + settings.AXES_SENSITIVE_PARAMETERS + if settings.AXES_PASSWORD_FORM_FIELD: + sensitive_parameters.append(settings.AXES_PASSWORD_FORM_FIELD) + + if sensitive_parameters: + cleansed = params.copy() + for param in sensitive_parameters: + if param in cleansed: + cleansed[param] = "********************" + return cleansed + return params + + def get_query_str(query: Type[QueryDict], max_length: int = 1024) -> str: """ Turns a query dictionary into an easy-to-read list of key-value pairs. - If a field is called either ``'password'`` or ``settings.AXES_PASSWORD_FORM_FIELD`` it will be excluded. + If a field is called either ``'password'`` or ``settings.AXES_PASSWORD_FORM_FIELD`` or if the fieldname is included + in ``settings.AXES_SENSITIVE_PARAMETERS`` its value will be masked. The length of the output is limited to max_length to avoid a DoS attack via excessively large payloads. """ - query_dict = query.copy() - query_dict.pop("password", None) - query_dict.pop(settings.AXES_PASSWORD_FORM_FIELD, None) + query_dict = cleanse_parameters(query.copy()) template = Template("$key=$value") items = [{"key": k, "value": v} for k, v in query_dict.items()] @@ -476,24 +500,3 @@ def toggleable(func) -> Callable: return func(*args, **kwargs) return inner - - -def cleanse_params(params: dict) -> dict: - """ - Replace sensitive parameter values in a parameter dict with - a safe placeholder value. - - Parameters to be cleansed are named in - ``settings.AXES_SENSITIVE_PARAMETERS``. If this setting is - empty, no parameters will be replaced. - - This is used to prevent passwords and similar values from - being logged in cleartext. - """ - if settings.AXES_SENSITIVE_PARAMETERS: - cleansed = params.copy() - for param in settings.AXES_SENSITIVE_PARAMETERS: - if param in cleansed: - cleansed[param] = "********************" - return cleansed - return params diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst index c7cb5d7..01e53c1 100644 --- a/docs/4_configuration.rst +++ b/docs/4_configuration.rst @@ -94,6 +94,9 @@ The following ``settings.py`` options are available for customizing Axes behavio Default: ``None`` * ``AXES_PASSWORD_FORM_FIELD``: the name of the form or credentials field that contains your users password. Default: ``password`` +* ``AXES_SENSITIVE_PARAMETERS``: Configures POST and GET parameter values (in addition to the value of + ``AXES_PASSWORD_FORM_FIELD``) to mask in login attempt logging. + Default: ``[]`` * ``AXES_NEVER_LOCKOUT_GET``: If ``True``, Axes will never lock out HTTP GET requests. Default: ``False`` * ``AXES_NEVER_LOCKOUT_WHITELIST``: If ``True``, users can always login from whitelisted IP addresses. @@ -111,8 +114,6 @@ The following ``settings.py`` options are available for customizing Axes behavio Default: ``False`` * ``AXES_ALLOWED_CORS_ORIGINS``: Configures lockout response CORS headers for XHR requests. Default: ``*`` -* ``AXES_SENSITIVE_PARAMS``: Configures POST and GET parameter values to mask in login attempt logging. - Default: ``['password',]`` The configuration option precedences for the access attempt monitoring are: diff --git a/tests/test_helpers.py b/tests/test_helpers.py index b856855..14cb557 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -23,7 +23,7 @@ from axes.helpers import ( is_ip_address_in_whitelist, is_user_attempt_whitelisted, toggleable, - cleanse_params, + cleanse_parameters, ) from axes.models import AccessAttempt from tests.base import AxesTestCase @@ -687,28 +687,36 @@ class AxesLockoutTestCase(AxesTestCase): class AxesCleanseParamsTestCase(AxesTestCase): def setUp(self): - self.params = { + self.parameters = { "username": "test_user", "password": "test_password", "other_sensitive_data": "sensitive", } - def test_cleanse_params(self): - cleansed = cleanse_params(self.params) + def test_cleanse_parameters(self): + cleansed = cleanse_parameters(self.parameters) self.assertEqual("test_user", cleansed["username"]) self.assertEqual("********************", cleansed["password"]) self.assertEqual("sensitive", cleansed["other_sensitive_data"]) @override_settings(AXES_SENSITIVE_PARAMETERS=["other_sensitive_data"]) - def test_cleanse_params_override(self): - cleansed = cleanse_params(self.params) + def test_cleanse_parameters_override_sensitive(self): + cleansed = cleanse_parameters(self.parameters) self.assertEqual("test_user", cleansed["username"]) - self.assertEqual("test_password", cleansed["password"]) + self.assertEqual("********************", cleansed["password"]) self.assertEqual("********************", cleansed["other_sensitive_data"]) - @override_settings(AXES_SENSITIVE_PARAMETERS=[]) - def test_cleanse_params_override_empty(self): - cleansed = cleanse_params(self.params) + @override_settings(AXES_SENSITIVE_PARAMETERS=["other_sensitive_data"]) + @override_settings(AXES_PASSWORD_FORM_FIELD="username") + def test_cleanse_parameters_override_both(self): + cleansed = cleanse_parameters(self.parameters) + self.assertEqual("********************", cleansed["username"]) + self.assertEqual("********************", cleansed["password"]) + self.assertEqual("********************", cleansed["other_sensitive_data"]) + + @override_settings(AXES_PASSWORD_FORM_FIELD=None) + def test_cleanse_parameters_override_empty(self): + cleansed = cleanse_parameters(self.parameters) self.assertEqual("test_user", cleansed["username"]) - self.assertEqual("test_password", cleansed["password"]) + self.assertEqual("********************", cleansed["password"]) self.assertEqual("sensitive", cleansed["other_sensitive_data"])