Django: Генерируем безопасные отчёты об ошибках на сайте

в 21:08, , рубрики: django, безопасность, Веб-разработка, информационная безопасность, конфиденциальные данные, метки: , ,

Как известно, в Django предусмотрен очень лёгкий и простой механизм уведомления разработчиков о возникающих проблемах. Когда проект развёрнут на локальном компьютере и в настройках DEBUG имеет значение True, то отчёты об ошибках просто выводятся в виде HTTP-ответа, в виде удобной страницы с возможностью копирования traceback'а.

Если же это production-сервер, и DEBUG имеет значение False, то отчёты по умолчанию отправляются по электронной почте всем, кто указан в настройке ADMINS (кстати, если вы используете SMTP-сервер, то письма могут не приходить, так как SMTP-сервер не принимает адрес root@localhost — в этом случае просто укажите любой другой адрес, который будет принимать ваш SMTP-сервер, с помощью настройки SERVER_EMAIL).

Разумеется, ничего не мешает также написать свой logging handler (обработчик журналирования) и сохранять отчёты об ошибках в любом нужном виде — создавать задачу в баг-трекере, например.

Тем не менее, если для вас важна безопасность ваших пользователей, то возникает вполне закономерный вопрос — как сделать так, чтобы отчёты об ошибках были для них безопасны? То есть как сделать, чтобы никакая личная информация в них не сохранялась, и не отправлялась кому-либо по почте (ведь дело даже не в том, что кто-то из разработчиков может вести себя недобросовестно, а скорее в том, что подобную информацию вообще лучше не сохранять где-либо за пределами сервера — ведь почтовый ящик и взломать могут, а сервер обычно защищён лучше).

На самом деле эта проблема очень легко решается в Django, и решение почти целиком описано в секции «How-to» официальной документации.

Для примера возьмём простое представление (view) для авторизации:

from django.http import HttpResponse, HttpResponseRedirect
from django.core.urlresolvers import reverse
from django.contrib.auth import authenticate, login

def login_view(request):
    if request.method != "POST":
        return HttpResponse("Please use POST.")

    user = authenticate(
        email=request.POST.get("email"),
        password=request.POST.get("password")
    )

    if user is not None:
        if user.is_active:
            login(request, user)
            status = "ok"
        else:
            status = "account_disabled"
    else:
        status = "invalid_credentials"

    if status != "ok":
        return HttpResponse(status)

    return HttpResponseRedirect(reverse('app.views.index'))

Если будете тестировать у себя, то не забудьте либо поменять email на username, либо добавить авторизационный бэкэнд для входа с помощью адреса электронной почты:

from django.contrib.auth.backends import ModelBackend
from django.contrib.admin.models import User

class EmailAuthBackend(ModelBackend):
    def authenticate(self, email=None, password=None, **kwargs):
        try:
            user = User.objects.get(email=email)  
        except User.DoesNotExist:
            return None
        except User.MultipleObjectsReturned:
            user = User.objects.filter(email=email)[0]

        if user.check_password(password):
            return user

        return None

А теперь посмотрим, что будет при возникновении какой-либо ошибки. На практике ошибка тут скорее всего может быть связана с недоступностью базы данных, но для теста можно просто добавить в начале функции вызов исключения:

raise Exception

Теперь, если кто-то попытается залогиниться, то по почте приходит отчёт, содержащий, в частности, информацию обо всех POST-параметрах запроса:

POST:<QueryDict: {u'csrfmiddlewaretoken': [u'F3d71EHWECfavaeK4H7nUTzLwgY07AHT'],
                  u'password': [u'123'],
                  u'email': [u'aruseni.magiku@gmail.com']}>

Ну, что ж, вот и email, и пароль. А теперь попробуем обернуть функцию в декоратор sensitive_post_parameters:

…
from django.views.decorators.debug import sensitive_post_parameters

@sensitive_post_parameters("password")
def login_view(request):
    …

Неплохо, теперь вместо пароля в отчёт включены 20 звёздочек (********************):

POST:<QueryDict: {u'csrfmiddlewaretoken': [u'F3d71EHWECfavaeK4H7nUTzLwgY07AHT'],
                  u'password': [u'********************'],
                  u'email': [u'aruseni.magiku@gmail.com']}>

Кстати, декоратор sensitive_post_parameters может принимать сразу несколько аргументов (в зависимости от того, значения скольки POST-параметров вы хотите скрыть в отчёте). А можно вообще не указывать аргументы:

…
from django.views.decorators.debug import sensitive_post_parameters

@sensitive_post_parameters()
def login_view(request):
    …

В этом случае в отчёте об ошибке оказываются скрыты значения вообще всех POST-параметров:

POST:<QueryDict: {u'csrfmiddlewaretoken': [u'********************'],
                  u'password': [u'********************'],
                  u'email': [u'********************']}>

Но личная информация, возможности раскрытия которой необходимо предотвратить, может содержаться не только в POST-параметрах, переданных в запросе, но и, например, в локальных переменных, которые определяет функция (и значения которых включаются в отчёты об ошибках). Представьте, к примеру, что у вас есть функция process_payment, которая, в частности, получает из базы данных номер банковской карты пользователя и записывает его в локальную переменную payment_card_id. Очевидно, что значение этой переменной нужно обязательно скрыть в отчёте об ошибке.

Сделать это можно с помощью декоратора sensitive_variables:

…
from django.views.decorators.debug import sensitive_variables

@sensitive_variables("payment_card_id")
def process_payment(request):
    …

Как и sensitive_post_parameters, sensitive_variables поддерживает использование нескольких аргументов (для того, чтобы скрыть большее количество переменных), а также использование без каких-либо аргументов (для скрытия всех локальных переменных функции).

Тем не менее, всё ещё остаётся некоторая деликатная информация — cookies. Которые, в частности, содержат идентификатор сессии (а возможный перехват идентификатора сессии это очень нехорошо).

COOKIES:{'csrftoken': 'F3d71EHWECfavaeK4H7nUTzLwgY07AHT',
         'sessionid': '262661787a7f42e787ad18ee853ef8d6'}

Что ж, это немного сложнее, но незначительно.

Добавим в приложение (здесь оно называется «app», у вас может быть как-то иначе) файл debug.py и добавим туда собственный класс фильтрации отчётов об ошибках (он будет наследоваться от класса SafeExceptionReporterFilter, который используется для фильтрации в тех случаях, когда были использованы декораторы sensitive_post_parameters и sensitive_variables):

from django.views.debug import SafeExceptionReporterFilter
from django.http import build_request_repr

class CustomExceptionReporterFilter(SafeExceptionReporterFilter):
    def get_cookies(self, request):
        if request is None:
            return {}
        else:
            cleansed = request.COOKIES.copy()
            for key, value in cleansed.iteritems():
                cleansed[key] = "secret"
            return cleansed

    def get_request_repr(self, request):
        if request is None:
            return repr(None)
        else:
            return build_request_repr(request, POST_override=self.get_post_parameters(request), COOKIES_override=self.get_cookies(request))

И укажем в настройках, что именно этот класс необходимо использовать для фильтрации:

DEFAULT_EXCEPTION_REPORTER_FILTER = 'app.views.CustomExceptionReporterFilter'

Ну что, теперь стало намного лучше:

COOKIES:{'csrftoken': 'secret',
         'sessionid': 'secret',
         'timezone': 'secret'}

Тем не менее, значения cookies всё ещё присутствуют в словаре META — META[&quote;CSRF_COOKIE&quote;] и META[&quote;HTTP_COOKIE&quote;]. Ну, давайте уберём их и оттуда. :)

from django.views.debug import SafeExceptionReporterFilter
from django.http import build_request_repr

class CustomExceptionReporterFilter(SafeExceptionReporterFilter):
    def get_cookies(self, request):
        if request is None:
            return {}
        else:
            cleansed = request.COOKIES.copy()
            for key, value in cleansed.iteritems():
                cleansed[key] = "secret"
            return cleansed

    def get_meta(self, request):
        if request is None:
            return {}
        else:
            cleansed = request.META.copy()
            for key in ("HTTP_COOKIE", "CSRF_COOKIE"):
                cleansed[key] = "secret"
            return cleansed

    def get_request_repr(self, request):
        if request is None:
            return repr(None)
        else:
            return build_request_repr(request, POST_override=self.get_post_parameters(request), COOKIES_override=self.get_cookies(request), META_override=self.get_meta(request))

Ну вот, теперь у вас есть уникальная возможность посмотреть на ошибку 500, испытывая при этом радость — осознавая, что пользователи теперь в большей безопасности.

Автор: MaGIc2laNTern

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js