Repository: encode/django-rest-framework

Issue:
Suppressed AttributeError When Authenticating
My implementation of [BaseAuthentication's authenticate](https://github.com/tomchristie/django-rest-framework/blob/dd3b47ccbda59487e3768f3397a563fd740f667a/rest_framework/authentication.py#L40) had a bug where an AttributeError was thrown. DRF handles that by catching the thrown AttributeError and then accessing and returning "user" on Django's request instead.

The [perform_authenticate](https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/views.py#L310) tries to authenticate when "user" is missing (see [invoke _authenticate](https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/request.py#L195) but if the code for authenticating throws an AttributeError, then Django's request's user will be accessed instead, see [`__getattribute__`](https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/request.py#L354-L359).

The result of this behaviour was that the request was authorised when entering the DRF view but when executing request.user inside the view an AnonymousUser instance was received instead of an instance of my class that inherits BaseAuthentication and implements authenticate(self, request).

AttributeError thrown when accessing "user" attribute on a DRF request shouldn't be caught and it shouldn't access user on Django's request.

One simple but ugly way to solve it would be to check if attr is "user" at https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/request.py#L357 and in that case rethrow the AttributeError instead of accessing the attribute on _request.



Discussion:
Unless we remove support for Python 2, using `hasattr` will not [be a better choice](https://hynek.me/articles/hasattr/).
I spent over 10 hours fixing this while porting raven to python 3 because of that subtle difference.

@xordoquy Sorry about that and thanks for answering.
However, that's not what I meant. Please see referenced pull request.

We found this problem when developing custom auth backends. Our implementation accidentally ate an AttributeError. This problem leads to difficult-to-debug problems. The AttributeError gets eaten because of the `__getattribute__` proxy to Django's request object. We're not sure how to actually fix this in the best way, but hopefully the test shows the issues.


Resolving patch:
diff --git a/rest_framework/request.py b/rest_framework/request.py
--- a/rest_framework/request.py
+++ b/rest_framework/request.py
@@ -10,6 +10,9 @@
 """
 from __future__ import unicode_literals
 
+import sys
+from contextlib import contextmanager
+
 from django.conf import settings
 from django.http import QueryDict
 from django.http.multipartparser import parse_header
@@ -59,6 +62,24 @@ def __exit__(self, *args, **kwarg):
         self.view.action = self.action
 
 
+class WrappedAttributeError(Exception):
+    pass
+
+
+@contextmanager
+def wrap_attributeerrors():
+    """
+    Used to re-raise AttributeErrors caught during authentication, preventing
+    these errors from otherwise being handled by the attribute access protocol.
+    """
+    try:
+        yield
+    except AttributeError:
+        info = sys.exc_info()
+        exc = WrappedAttributeError(str(info[1]))
+        six.reraise(type(exc), exc, info[2])
+
+
 class Empty(object):
     """
     Placeholder for unset attributes.
@@ -191,7 +212,8 @@ def user(self):
         by the authentication classes provided to the request.
         """
         if not hasattr(self, '_user'):
-            self._authenticate()
+            with wrap_attributeerrors():
+                self._authenticate()
         return self._user
 
     @user.setter
@@ -214,7 +236,8 @@ def auth(self):
         request, such as an authentication token.
         """
         if not hasattr(self, '_auth'):
-            self._authenticate()
+            with wrap_attributeerrors():
+                self._authenticate()
         return self._auth
 
     @auth.setter
@@ -233,7 +256,8 @@ def successful_authenticator(self):
         to authenticate the request, or `None`.
         """
         if not hasattr(self, '_authenticator'):
-            self._authenticate()
+            with wrap_attributeerrors():
+                self._authenticate()
         return self._authenticator
 
     def _load_data_and_files(self):
@@ -316,7 +340,7 @@ def _parse(self):
 
         try:
             parsed = parser.parse(stream, media_type, self.parser_context)
-        except:
+        except Exception:
             # If we get an exception during parsing, fill in empty data and
             # re-raise.  Ensures we don't simply repeat the error when
             # attempting to render the browsable renderer response, or when


Test patch:
diff --git a/tests/test_request.py b/tests/test_request.py
--- a/tests/test_request.py
+++ b/tests/test_request.py
@@ -6,8 +6,10 @@
 import os.path
 import tempfile
 
+import pytest
 from django.conf.urls import url
 from django.contrib.auth import authenticate, login, logout
+from django.contrib.auth.middleware import AuthenticationMiddleware
 from django.contrib.auth.models import User
 from django.contrib.sessions.middleware import SessionMiddleware
 from django.core.files.uploadedfile import SimpleUploadedFile
@@ -17,7 +19,7 @@
 from rest_framework import status
 from rest_framework.authentication import SessionAuthentication
 from rest_framework.parsers import BaseParser, FormParser, MultiPartParser
-from rest_framework.request import Request
+from rest_framework.request import Request, WrappedAttributeError
 from rest_framework.response import Response
 from rest_framework.test import APIClient, APIRequestFactory
 from rest_framework.views import APIView
@@ -185,7 +187,8 @@ def setUp(self):
         # available to login and logout functions
         self.wrapped_request = factory.get('/')
         self.request = Request(self.wrapped_request)
-        SessionMiddleware().process_request(self.request)
+        SessionMiddleware().process_request(self.wrapped_request)
+        AuthenticationMiddleware().process_request(self.wrapped_request)
 
         User.objects.create_user('ringo', 'starr@thebeatles.com', 'yellow')
         self.user = authenticate(username='ringo', password='yellow')
@@ -200,9 +203,9 @@ def test_user_can_login(self):
 
     def test_user_can_logout(self):
         self.request.user = self.user
-        self.assertFalse(self.request.user.is_anonymous)
+        assert not self.request.user.is_anonymous
         logout(self.request)
-        self.assertTrue(self.request.user.is_anonymous)
+        assert self.request.user.is_anonymous
 
     def test_logged_in_user_is_set_on_wrapped_request(self):
         login(self.request, self.user)
@@ -215,22 +218,27 @@ def test_calling_user_fails_when_attribute_error_is_raised(self):
         """
         class AuthRaisesAttributeError(object):
             def authenticate(self, request):
-                import rest_framework
-                rest_framework.MISSPELLED_NAME_THAT_DOESNT_EXIST
+                self.MISSPELLED_NAME_THAT_DOESNT_EXIST
 
-        self.request = Request(factory.get('/'), authenticators=(AuthRaisesAttributeError(),))
-        SessionMiddleware().process_request(self.request)
+        request = Request(self.wrapped_request, authenticators=(AuthRaisesAttributeError(),))
 
-        login(self.request, self.user)
-        try:
-            self.request.user
-        except AttributeError as error:
-            assert str(error) in (
-                "'module' object has no attribute 'MISSPELLED_NAME_THAT_DOESNT_EXIST'",  # Python < 3.5
-                "module 'rest_framework' has no attribute 'MISSPELLED_NAME_THAT_DOESNT_EXIST'",  # Python >= 3.5
-            )
-        else:
-            assert False, 'AttributeError not raised'
+        # The middleware processes the underlying Django request, sets anonymous user
+        assert self.wrapped_request.user.is_anonymous
+
+        # The DRF request object does not have a user and should run authenticators
+        expected = r"no attribute 'MISSPELLED_NAME_THAT_DOESNT_EXIST'"
+        with pytest.raises(WrappedAttributeError, match=expected):
+            request.user
+
+        # python 2 hasattr fails for *any* exception, not just AttributeError
+        if six.PY2:
+            return
+
+        with pytest.raises(WrappedAttributeError, match=expected):
+            hasattr(request, 'user')
+
+        with pytest.raises(WrappedAttributeError, match=expected):
+            login(request, self.user)
 
 
 class TestAuthSetter(TestCase):