Repository: encode/django-rest-framework

Issue:
PUT request falls back to creating object if it has been filtered out
If one makes a `PUT` request to an URL which is already occupied by an existing object, but this object has been previously filtered out, 404 exception will be raised by `get_object` function (which is correct). This exception will however be catched by [get_object_or_none](https://github.com/tomchristie/django-rest-framework/blob/999056cde1c6355d5ca036f109b35b41cb9d47cc/rest_framework/mixins.py#L141) function which will return `None` to indicate that new object can be created. Therefore an attempt to create the object will be performed which will often result in `IntegrityError` and 500 status code being returned.

This doesn’t seem to be an easy issue to fix. `get_queryset` could be used to get unfiltered queryset and check if such an object already exists, but… overriding get_queryset is a documented method of filtering, so it doesn’t have to work. Also I’m not sure whether we should return 403 or 404 in this case.



Discussion:
Interesting, yeah.  I guess there's a simpler version of this problem, where the `queryset` attribute is exposing a subset of the actual instances, and a PUT request that would recreate one of the instances that does exist, but isn't in that queryset then fails.  I guess we ought to return some type of 4xx response.  409 might be reasonable if it's okay for the client to know that a conflicting object exists.

You had any further thoughts on what would be sensible behaviour here or how to address this?

I agree with the reasonableness of the HTTP 409. The client needs to be told that the request cannot be completed due to some sort of conflict. The only other status that may make sense is a 403 but I think that could lead to confusion based on the assumption that 403s are generally permission issues.

Nice, I met exactly the same issue when moving my views to viewsets today and was wondering what to do.

Actually `ValidationError` is raised instead of `IntegrityError`, because [additional call of `full_clean` is permormed in `UpdateModelMixin` after setting the primary key](https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/mixins.py#L174). This is generally a good thing, but this exception isn’t catched, which results in 500 status code, which is bad. So one solution would be to refactor the code to catch this exception. This will give us 400 status code, which may be good enough.

I’ve created a failing test draft (works, but needs to be written better): https://github.com/KrzysiekJ/django-rest-framework/commit/d21669250e5842b07b4d038c925f1ef878207e78

I don’t see an easy way to hide the information that some object exists under an URL associated with a specific primary key, if that primary key is the only cause of problems. With the (slow) arrival of composite primary keys, which may contain concrete data, to Django, this may be considered a security issue, but… in such case 403 should be raised earlier to indicate that an user cannot make a PUT request to a particular URL (if he is allowed to make such request, then information leak also seems unavoidable, but it shouldn’t be that harmful). So this is actually a separate issue.

I use get_queryset to limit a user's visibility to only his profile.  In this case, a PUT request to another user's pk would ideally return a 404—same as a GET request to an object outside the queryset.  If that doesn't make sense, then DELETE's behavior of returning 403 would be my second pick.

If anyone else encounters this in the interim, you can override `update` as a workaround:

``` python
from django.http import Http404

class UserViewSet(viewsets.ModelViewSet):
    model = User

    def get_queryset(self):
        return User.objects.filter(pk=self.request.user.pk)

    def update(self, request, pk=None):
        try:
            self.get_object()
        except Http404:
            return Response(
                {'detail': 'Not found'}, status=status.HTTP_404_NOT_FOUND)
        return super(UserViewSet, self).update(request, pk)
```


Resolving patch:
diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py
--- a/rest_framework/mixins.py
+++ b/rest_framework/mixins.py
@@ -6,6 +6,7 @@
 """
 from __future__ import unicode_literals
 
+from django.core.exceptions import ValidationError
 from django.http import Http404
 from rest_framework import status
 from rest_framework.response import Response
@@ -127,7 +128,12 @@ def update(self, request, *args, **kwargs):
                                          files=request.FILES, partial=partial)
 
         if serializer.is_valid():
-            self.pre_save(serializer.object)
+            try:
+                self.pre_save(serializer.object)
+            except ValidationError as err:
+                # full_clean on model instance may be called in pre_save, so we
+                # have to handle eventual errors.
+                return Response(err.message_dict, status=status.HTTP_400_BAD_REQUEST)
             self.object = serializer.save(**save_kwargs)
             self.post_save(self.object, created=created)
             return Response(serializer.data, status=success_status_code)


Test patch:
diff --git a/rest_framework/tests/test_generics.py b/rest_framework/tests/test_generics.py
--- a/rest_framework/tests/test_generics.py
+++ b/rest_framework/tests/test_generics.py
@@ -23,6 +23,10 @@ class InstanceView(generics.RetrieveUpdateDestroyAPIView):
     """
     model = BasicModel
 
+    def get_queryset(self):
+        queryset = super(InstanceView, self).get_queryset()
+        return queryset.exclude(text='filtered out')
+
 
 class SlugSerializer(serializers.ModelSerializer):
     slug = serializers.Field()  # read only
@@ -160,10 +164,10 @@ def setUp(self):
         """
         Create 3 BasicModel intances.
         """
-        items = ['foo', 'bar', 'baz']
+        items = ['foo', 'bar', 'baz', 'filtered out']
         for item in items:
             BasicModel(text=item).save()
-        self.objects = BasicModel.objects
+        self.objects = BasicModel.objects.exclude(text='filtered out')
         self.data = [
             {'id': obj.id, 'text': obj.text}
             for obj in self.objects.all()
@@ -352,6 +356,17 @@ def test_put_to_deleted_instance(self):
         updated = self.objects.get(id=1)
         self.assertEqual(updated.text, 'foobar')
 
+    def test_put_to_filtered_out_instance(self):
+        """
+        PUT requests to an URL of instance which is filtered out should not be
+        able to create new objects.
+        """
+        data = {'text': 'foo'}
+        filtered_out_pk = BasicModel.objects.filter(text='filtered out')[0].pk
+        request = factory.put('/{0}'.format(filtered_out_pk), data, format='json')
+        response = self.view(request, pk=filtered_out_pk).render()
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
     def test_put_as_create_on_id_based_url(self):
         """
         PUT requests to RetrieveUpdateDestroyAPIView should create an object