Repository: paperless-ngx/paperless-ngx

Issue:
[BUG] Document API not checking if user has access to view a document
### Description

Paperless is allowing users to preview documents they do not have permissions to. Due to the sequential nature of paperless's document IDs, it is easy for a user to guess/replace doc IDs in the preview URL to find documents they should not be able to.

I would expect the document API to check the doc ID against the user permissions before returning the document.

### Steps to reproduce

1. A user previews a document they have access to - i.e. http://paperless.local/api/documents/[DocID]/preview/
2. User changes the DocID in the URL to an ID they do not have permission to access
3. Paperless displays the document the user should not have access to

### Webserver logs

```bash
No logs as no permission check is being performed.
```


### Browser logs

_No response_

### Paperless-ngx version

1.14.0

### Host OS

Debian 11

### Installation method

Docker - official image

### Browser

_No response_

### Configuration changes

_No response_

### Other

_No response_


Discussion:
Probably applies to all the `@action` types of `DocumentViewSet`
Yep

Resolving patch:
diff --git a/src/documents/permissions.py b/src/documents/permissions.py
--- a/src/documents/permissions.py
+++ b/src/documents/permissions.py
@@ -9,6 +9,7 @@
 from guardian.shortcuts import remove_perm
 from rest_framework.permissions import BasePermission
 from rest_framework.permissions import DjangoObjectPermissions
+from guardian.core import ObjectPermissionChecker
 
 
 class PaperlessObjectPermissions(DjangoObjectPermissions):
@@ -114,3 +115,8 @@ def get_objects_for_user_owner_aware(user, perms, Model):
         accept_global_perms=False,
     )
     return objects_owned | objects_unowned | objects_with_perms
+
+
+def has_perms_owner_aware(user, perms, obj):
+    checker = ObjectPermissionChecker(user)
+    return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
diff --git a/src/documents/views.py b/src/documents/views.py
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -23,7 +23,7 @@
 from django.db.models import When
 from django.db.models.functions import Length
 from django.db.models.functions import Lower
-from django.http import Http404
+from django.http import Http404, HttpResponseForbidden
 from django.http import HttpResponse
 from django.http import HttpResponseBadRequest
 from django.shortcuts import get_object_or_404
@@ -33,7 +33,7 @@
 from django.views.generic import TemplateView
 from django_filters.rest_framework import DjangoFilterBackend
 from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
-from documents.permissions import PaperlessAdminPermissions
+from documents.permissions import PaperlessAdminPermissions, has_perms_owner_aware
 from documents.permissions import PaperlessObjectPermissions
 from documents.tasks import consume_file
 from langdetect import detect
@@ -59,7 +59,6 @@
 from rest_framework.viewsets import ModelViewSet
 from rest_framework.viewsets import ReadOnlyModelViewSet
 from rest_framework.viewsets import ViewSet
-
 from .bulk_download import ArchiveOnlyStrategy
 from .bulk_download import OriginalAndArchiveStrategy
 from .bulk_download import OriginalsOnlyStrategy
@@ -295,6 +294,12 @@ def original_requested(request):
 
     def file_response(self, pk, request, disposition):
         doc = Document.objects.get(id=pk)
+        if request.user is not None and not has_perms_owner_aware(
+            request.user,
+            "view_document",
+            doc,
+        ):
+            return HttpResponseForbidden("Insufficient permissions")
         if not self.original_requested(request) and doc.has_archive_version:
             file_handle = doc.archive_file
             filename = doc.get_public_filename(archive=True)
@@ -354,6 +359,12 @@ def get_filesize(self, filename):
     def metadata(self, request, pk=None):
         try:
             doc = Document.objects.get(pk=pk)
+            if request.user is not None and not has_perms_owner_aware(
+                request.user,
+                "view_document",
+                doc,
+            ):
+                return HttpResponseForbidden("Insufficient permissions")
         except Document.DoesNotExist:
             raise Http404
 
@@ -391,6 +402,12 @@ def metadata(self, request, pk=None):
     @action(methods=["get"], detail=True)
     def suggestions(self, request, pk=None):
         doc = get_object_or_404(Document, pk=pk)
+        if request.user is not None and not has_perms_owner_aware(
+            request.user,
+            "view_document",
+            doc,
+        ):
+            return HttpResponseForbidden("Insufficient permissions")
 
         classifier = load_classifier()
 
@@ -430,6 +447,12 @@ def preview(self, request, pk=None):
     def thumb(self, request, pk=None):
         try:
             doc = Document.objects.get(id=pk)
+            if request.user is not None and not has_perms_owner_aware(
+                request.user,
+                "view_document",
+                doc,
+            ):
+                return HttpResponseForbidden("Insufficient permissions")
             if doc.storage_type == Document.STORAGE_TYPE_GPG:
                 handle = GnuPG.decrypted(doc.thumbnail_file)
             else:
@@ -468,6 +491,12 @@ def getNotes(self, doc):
     def notes(self, request, pk=None):
         try:
             doc = Document.objects.get(pk=pk)
+            if request.user is not None and not has_perms_owner_aware(
+                request.user,
+                "view_document",
+                doc,
+            ):
+                return HttpResponseForbidden("Insufficient permissions")
         except Document.DoesNotExist:
             raise Http404
 


Test patch:
diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py
--- a/src/documents/tests/test_api.py
+++ b/src/documents/tests/test_api.py
@@ -206,6 +206,67 @@ def test_document_actions(self):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.content, content_thumbnail)
 
+    def test_document_actions_with_perms(self):
+        """
+        GIVEN:
+            - Document with owner and without granted permissions
+            - User is then granted permissions
+        WHEN:
+            - User tries to load preview, thumbnail
+        THEN:
+            - Initially, HTTP 403 Forbidden
+            - With permissions, HTTP 200 OK
+        """
+        _, filename = tempfile.mkstemp(dir=self.dirs.originals_dir)
+
+        content = b"This is a test"
+        content_thumbnail = b"thumbnail content"
+
+        with open(filename, "wb") as f:
+            f.write(content)
+
+        user1 = User.objects.create_user(username="test1")
+        user2 = User.objects.create_user(username="test2")
+        user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
+        user2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
+
+        self.client.force_authenticate(user2)
+
+        doc = Document.objects.create(
+            title="none",
+            filename=os.path.basename(filename),
+            mime_type="application/pdf",
+            owner=user1,
+        )
+
+        with open(
+            os.path.join(self.dirs.thumbnail_dir, f"{doc.pk:07d}.webp"),
+            "wb",
+        ) as f:
+            f.write(content_thumbnail)
+
+        response = self.client.get(f"/api/documents/{doc.pk}/download/")
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        response = self.client.get(f"/api/documents/{doc.pk}/preview/")
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        response = self.client.get(f"/api/documents/{doc.pk}/thumb/")
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        from guardian.shortcuts import assign_perm
+
+        assign_perm("view_document", user2, doc)
+
+        response = self.client.get(f"/api/documents/{doc.pk}/download/")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        response = self.client.get(f"/api/documents/{doc.pk}/preview/")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        response = self.client.get(f"/api/documents/{doc.pk}/thumb/")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
     @override_settings(FILENAME_FORMAT="")
     def test_download_with_archive(self):