Source code for pwned_passwords_django.middleware

"""
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 asyncio
import logging
import re
import typing

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


@sensitive_variables()
async def _scan_payload_async(request: http.HttpRequest) -> typing.List[str]:
    """
    Asynchronous helper function which performs the scan of the request's payload.

    """
    settings_dict = getattr(settings, "PWNED_PASSWORDS", {})
    search_re = re.compile(settings_dict.get("PASSWORD_REGEX", r"PASS"), re.IGNORECASE)

    keys_to_search = [key for key in request.POST.keys() if search_re.search(key)]
    if not keys_to_search:
        return []
    try:
        return [
            key
            for key in keys_to_search
            if await api.check_password_async(request.POST[key])
        ]
    except exceptions.PwnedPasswordsError:
        logger.error(
            "Falling back to Django CommonPasswordValidator due "
            "to error contacting Pwned Passwords."
        )
        return [key for key in keys_to_search if _fallback(request.POST[key])]


@sensitive_variables()
def _scan_payload_sync(request: http.HttpRequest) -> typing.List[str]:
    """
    Helper function which performs the scan of the request's payload.

    """
    settings_dict = getattr(settings, "PWNED_PASSWORDS", {})
    search_re = re.compile(settings_dict.get("PASSWORD_REGEX", r"PASS"), re.IGNORECASE)

    keys_to_search = [key for key in request.POST.keys() if search_re.search(key)]
    if not keys_to_search:
        return []
    try:
        return [key for key in keys_to_search if api.check_password(request.POST[key])]
    except exceptions.PwnedPasswordsError:
        logger.error(
            "Falling back to Django CommonPasswordValidator due "
            "to error contacting Pwned Passwords."
        )
        return [key for key in keys_to_search if _fallback(key)]


[docs] @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. """ # 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 asyncio.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": # A bug in Django's async test client causes access to request.POST to # throw an exception unless preceded by an access to # request.body. Future versions of Django will fix this, but for now we # do a throwaway access of request.body as a workaround. # # See https://code.djangoproject.com/ticket/34063 for details. request.body # pylint: disable=pointless-statement request.pwned_passwords = await _scan_payload_async(request) 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(request) response = get_response(request) return response return middleware