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

  • Tutorial
Как известно, в 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.debug.CustomExceptionReporterFilter'

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

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

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

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, испытывая при этом радость — осознавая, что пользователи теперь в большей безопасности.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 11

    +6
    +1
    Кстати, метод get_cookies можно упростить:

    def get_cookies(self, request):
        if request is None:
            return {}
                
        return {key: 'secret' for key in request.COOKIES}
    

    Спасибо Dreadatour!

    Принцип с else общий — если в конце блока if есть return, то всё то, что должно выполняться в случае, если условие if не расценивается как истинное, можно помещать не в блок else, а просто размещать сразу после блока if, без каких-либо блоков — потому что если условие if было расценено как истинное, то всё, что ниже, всё равно уже никогда не выполнится (даже если было вызвано исключение).
      0
      Мне, кажется, это только наоборот усложняет восприятие программы. Конструкция if-else-then визуально воспринимается проще и логика более простая, чем в «упрощённом» варианте.
        0
        Просто представьте, что у вас не одно условие для return, а хотя бы три, что бывает довольно часто.
          0
          Представить можно много чего, мы ведь говорим про конкретное упрощение кода, представленного выше, а не про все на свете варианты. Я высказал мысль о том, как бы я записал конкретный код, рассмотренный выше, а не абстрактный.
          +1
          KISS
            0
            Я вообще не спорю, просто захотелось мыслями поделиться. Для меня KISS — тот вариант, про который я пишу, с явным else
        +1
        За пост спасибо!

        Но при авторизации скрытие email грозит, тем что можно долго искать проблему с дублированием email в другом регистре, что по де-фолту для джанги актуально. Во-первых, в стандартной модели пользователя email не уникальный. Во-вторых в стандратном поле EmailField нормализируется только домен. Таким образом, если вы все использовали из коробки и при регистрации сами не валидилировали email на уникальность с учетом возможного использования разного регистра символов то в зависимости от вашего бэкэнда утентификации при авторизации могут быть ошибки из-за того что в базе есть разные пользователи с адресами aruseni.magiku@gmail.com и Aruseni.Magiku@gmail.com. На рабочем сайте с большой базой пользователей вы можете долго искать эту проблему. Знание же для какого именно пользователя проблема актуальна в данном случае позволяет установить ее причину гораздо быстрее.
          0
          Спасибо, что подняли данную тему. Такая проблема действительно существует, если специально не позаботиться об обработке адресов электронной почты, набранных с использованием букв в разном регистре.

          Реально регистрация может быть, например, такой (обработка IntegrityError тут для маловероятной ситуации race condition в случае одновременной регистрации двух пользователей):

          Скрытый текст
          if request.method == "POST":
              form = RegistrationForm(request.POST, request.FILES)
              if form.is_valid():
                  email = form.cleaned_data["email"].lower()
                  password = form.cleaned_data["password"]
                  try:
                      user = User.objects.create_user(hashlib.sha1(email).hexdigest()[:30],
                                                 email,
                                                 password)
                      user.username = "".join(["user", str(user.id)])
                      user.save()
                  except IntegrityError:
                      messages.error(request, u"При регистрации возникла ошибка. Пожалуйста, попробуйте ещё раз.")
                      try:
                          # Rollback the transaction (otherwise a DatabaseError would be raised when trying to delete the user)
                          connection._rollback()
                          # In case the IntegrityError was raised when renaming the user
                          user.delete()
                      # UnboundLocalError can be raised if the user variable is not defined
                      except UnboundLocalError:
                          pass
                      return HttpResponseRedirect(reverse('dating.views.index'))
                  UserProfile.objects.create(user=user,
                                             name=form.cleaned_data["name"],
                                             photo=form.cleaned_data["photo"])
          
                  user = authenticate(username=user.username, password=password)
                  login(request, user)
          
                  email_body = render_to_string('registration_email.txt', {
                      "username": form.cleaned_data["name"], "user_id": user.id
                  })
                  send_mail('Background Dating',
                            email_body,
                            None,
                            [email],
                            fail_silently=True)
          
                  return HttpResponseRedirect(reverse('dating.views.index'))
          else:
              form = RegistrationForm()
          
          return render_to_response('index.html',
                                    {"registration_form": form},
                                    context_instance=RequestContext(request))
          

          Форма регистрации с её родительским классом:

          class UserProfileForm(forms.ModelForm):
              def clean_photo(self):
                  image = self.cleaned_data.get('photo')
                  if image:
                      if not "." in image.name:
                          raise ValidationError(u"Пожалуйста, убедитесь, что файл имеет расширение.")
          
                      extension = image.name.rsplit('.',1)[1].lower()
          
                      if extension not in ["jpg", "jpeg", "gif", "png"]:
                          raise ValidationError(u"Пожалуйста, убедитесь, что изображение имеет одно из следующих расширений: jpg, jpeg, png, gif.")
          
                      if image._size > 1*1024*1024:
                          raise ValidationError(u"Пожалуйста, убедитесь, что размер изображения не больше 1 MiB.")
          
                      im = Image.open(StringIO.StringIO(image.read()))
          
                      # A dictionary of expected file extensions for each format (as returned by PIL)
                      expected_file_extensions = {"JPEG": ["jpg", "jpeg"], "PNG": ["png"], "GIF": ["gif"]}
          
                      if im.format not in expected_file_extensions:
                          raise ValidationError(u"Пожалуйста, убедитесь, что изображение имеет один из следующих форматов: JPEG, PNG, GIF.")
          
                      if extension not in expected_file_extensions[im.format]:
                          raise ValidationError(u"Пожалуйста, убедитесь, что формат изображения соответствует его расширению.")
          
                      if (im.size[0] < 300 or im.size[1] < 200):
                          raise ValidationError(u"Пожалуйста, убедитесь, что разрешение фотографии не меньше 300×200.")
          
                      if (max(im.size) > 10000):
                          raise ValidationError(u"Пожалуйста, убедитесь, что разрешение фотографии не больше 10000×10000.")
          
                      return image
                  else:
                      raise ValidationError(u"При получении изображения возникла ошибка")
          
              class Meta:
                  model = UserProfile
          
          class RegistrationForm(UserProfileForm):
              email = forms.EmailField(max_length=75)
              password = forms.CharField(max_length=128)
          
              def clean_email(self):
                  email = self.cleaned_data['email'].lower()
                  if User.objects.filter(email=email).exists():
                      raise forms.ValidationError(u"Пользователь с таким адресом электронной почты уже есть на сайте.")
          
                  return email
          
              class Meta:
                  model = UserProfile
                  exclude = ('user',)
          

          Соответственно, в бэкэнде авторизации просто позволяем пользователю вводить email с использованием букв любого регистра:

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


          К счастью, в Django 1.5 обязательность танцев с бубном заметно уменьшится в связи с появлением определяемых разработчиком моделей User.

        Only users with full accounts can post comments. Log in, please.