-
Notifications
You must be signed in to change notification settings - Fork 8
/
Copy pathmiddleware.py
223 lines (176 loc) · 8.64 KB
/
middleware.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""
A Django middleware which checks all incoming POST requests for
potentially-compromised passwords using the Pwned Passwords API.
"""
# SPDX-License-Identifier: BSD-3-Clause
import logging
import re
import typing
from inspect import iscoroutinefunction
from django import http
from django.conf import settings
from django.contrib.auth.password_validation import CommonPasswordValidator
from django.core.exceptions import ValidationError
from django.utils.decorators import sync_and_async_middleware
from django.views.decorators.debug import sensitive_variables
from . import api, exceptions
logger = logging.getLogger(__name__)
_fallback_validator = CommonPasswordValidator()
def _fallback(password: str) -> bool:
"""
Fallback password check in case of Pwned Passwords errors, using Django's
built-in CommonPasswordValidator.
"""
try:
_fallback_validator.validate(password)
return False
except ValidationError:
return True
def _get_potential_passwords(
payload: http.QueryDict, pattern: re.Pattern
) -> list[tuple[str, str]]:
"""
Return the set of keys in the request payload which are potentially passwords
(according to the given pattern).
"""
potential_passwords = []
for key, value in payload.lists():
if pattern.search(key):
# This is the only potentially tricky bit. If multiple values were submitted
# for the same key, we want to make sure we check all of them.
for item in value:
potential_passwords.append((key, item))
return potential_passwords
@sensitive_variables()
async def _scan_payload_async(
keys_to_search: list[tuple[str, str]],
) -> typing.List[str]:
"""
Asynchronous helper function which performs the scan of the request's payload.
"""
if not keys_to_search:
return []
try:
return [
key
for key, value in keys_to_search
if await api.check_password_async(value)
]
except exceptions.PwnedPasswordsError:
logger.error(
"Falling back to Django CommonPasswordValidator due "
"to error contacting Pwned Passwords."
)
return [key for key, value in keys_to_search if _fallback(value)]
@sensitive_variables()
def _scan_payload_sync(keys_to_search: list[tuple[str, str]]) -> typing.List[str]:
"""
Helper function which performs the scan of the request's payload.
"""
if not keys_to_search:
return []
try:
return [key for key, value in keys_to_search if api.check_password(value)]
except exceptions.PwnedPasswordsError:
logger.error(
"Falling back to Django CommonPasswordValidator due "
"to error contacting Pwned Passwords."
)
return [key for key, value in keys_to_search if _fallback(value)]
@sync_and_async_middleware
def pwned_passwords_middleware(get_response: typing.Callable) -> typing.Callable:
"""
Factory function returning a middleware -- sync or async as necessary -- which
checks ``POST`` submissions that potentially contain passwords against the Pwned
Passwords database.
To enable the middleware, add
``"pwned_passwords_django.middleware.pwned_passwords_middleware"`` to your
:setting:`MIDDLEWARE` setting. This will add a new attribute -- ``pwned_passwords``
-- to each :class:`~django.http.HttpRequest` object. The ``request.pwned_passwords``
attribute will be a :class:`list` of :class:`str`.
.. warning:: **Middleware order**
The order of middleware classes in the Django :setting:`MIDDLEWARE` setting can
be sensitive. In particular, any middlewares which affect file upload handlers
*must* be listed above middlewares which inspect
:attr:`~django.http.HttpRequest.POST`. Since this middleware has to inspect
:attr:`~django.http.HttpRequest.POST` for likely passwords, it must be listed
after any middlewares which might change upload handlers. If you're unsure what
this means, just put this middleware at the bottom of your :setting:`MIDDLEWARE`
list.
The ``request.pwned_passwords`` list will be *empty* if any of the following is
true:
* The request method is not ``POST``.
* The request method is ``POST``, but the payload does not appear to contain a
password.
* The request method is ``POST``, and the payload appears to contain one or more
passwords, but none were listed as compromised in Pwned Passwords.
If the request method is ``POST``, and the payload appears to contain one or more
passwords, and at least one of those is listed in Pwned Passwords, then
``request.pwned_passwords`` will be a list of keys from ``request.POST`` that
contained compromised passwords.
For example, if ``request.POST`` contains a key named ``password_field``, and
``request.POST["password_field"]`` is a password that appears in the Pwned Passwords
database, ``request.pwned_passwords`` will be ``["password_field"]``.
.. warning:: **API failures**
``pwned-passwords-django`` needs to communicate with the Pwned Passwords API in
order to check passwords. If Pwned Passwords is down or timing out (the default
connection timeout is 1 second), or if any other error occurs when checking the
password, this middleware will fall back to using Django's
:class:`~django.contrib.auth.password_validation.CommonPasswordValidator`, which
uses a smaller, locally-stored list of common passwords. Whenever this happens, a
message of level :data:`logging.ERROR` will appear in your logs, indicating what
type of failure was encountered in talking to the Pwned Passwords API.
See :ref:`the error-handling documentation <error-handling>` for details.
Here's an example of how you might use `Django's message framework
<https://docs.djangoproject.com/en/stable/ref/contrib/messages/>`_ to indicate to a
user that they've just submitted a password that appears to be compromised:
.. code-block:: python
from django.contrib import messages
def some_view(request):
if request.method == "POST" and request.pwned_passwords:
messages.warning(
request,
"You just entered a password which appears to be compromised!"
)
``pwned-passwords-django`` uses a regular expression to guess which items in
:attr:`~django.http.HttpRequest.POST` are likely to be passwords. By default, it
matches on any key in :attr:`~django.http.HttpRequest.POST` containing ``"PASS"``
(case-insensitive), which catches input names like ``"password"``, ``"passphrase"``,
and so on. If you use something significantly different than this for a password
input name, specify it -- as a string, *not* as a compiled regex object! -- in the
setting ``settings.PWNED_PASSWORDS["PASSWORD_REGEX"]`` to tell the middleware what
to look for. See :ref:`the settings documentation <settings>` for details.
"""
settings_dict = getattr(settings, "PWNED_PASSWORDS", {})
search_re = re.compile(settings_dict.get("PASSWORD_REGEX", r"PASS"), re.IGNORECASE)
# We need to know whether or not the request we're handling is async: if it is, we
# should return an async middleware that uses an async HTTP client to talk to Pwned
# Passwords. We determine that by checking whether the get_response() callable is a
# coroutine -- if so, we're on the async path.
if iscoroutinefunction(get_response):
async def middleware(request: http.HttpRequest) -> http.HttpResponse:
"""
Asynchronous middleware function which checks all POST submissions
containing likely passwords against the Pwned Passwords database.
"""
request.pwned_passwords = []
if request.method == "POST":
request.pwned_passwords = await _scan_payload_async(
_get_potential_passwords(request.POST, search_re)
)
response = await get_response(request)
return response
else:
def middleware(request: http.HttpRequest) -> http.HttpResponse:
"""
Synchronous middleware function which checks all POST submissions
containing likely passwords against the Pwned Passwords database.
"""
request.pwned_passwords = []
if request.method == "POST":
request.pwned_passwords = _scan_payload_sync(
_get_potential_passwords(request.POST, search_re)
)
response = get_response(request)
return response
return middleware