mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-02 16:07:15 +00:00
Security: Minor additional hardening (#13898)
* Security: bump jinja2 floor to 3.1.6 (CVE-2025-27516) * Security: anchor the /share/ URL pattern * Security: handle missing file on public share view without 500 * Security: scope correspondent last_correspondence to permitted documents * Security: disable PUT/PATCH on share link bundles
This commit is contained in:
@@ -102,6 +102,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
||||
- API is called
|
||||
THEN:
|
||||
- Last correspondence date is returned only if requested for list, and for detail
|
||||
- The date is scoped to documents the requesting user may view
|
||||
"""
|
||||
|
||||
Document.objects.create(
|
||||
@@ -145,6 +146,32 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
||||
response.data["last_correspondence"],
|
||||
)
|
||||
|
||||
# A newer document owned by another user must not leak through the
|
||||
# aggregate for a non-superuser who cannot view it
|
||||
other = User.objects.create_user(username="other")
|
||||
Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
correspondent=self.c1,
|
||||
created=datetime.date(2023, 6, 1),
|
||||
checksum="hidden",
|
||||
owner=other,
|
||||
)
|
||||
|
||||
user = User.objects.create_user(username="regular")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_correspondent"),
|
||||
)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.get("/api/correspondents/?last_correspondence=true")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
result = next(r for r in response.data["results"] if r["id"] == self.c1.id)
|
||||
self.assertIn("2022-01-02", result["last_correspondence"])
|
||||
|
||||
response = self.client.get(f"/api/correspondents/{self.c1.id}/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("2022-01-02", response.data["last_correspondence"])
|
||||
|
||||
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
|
||||
response_v10 = self.client.get("/api/correspondents/")
|
||||
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -192,6 +192,50 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||
|
||||
def test_share_link_missing_file_redirects(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A share link whose document file is missing from disk
|
||||
WHEN:
|
||||
- The public share link is requested anonymously
|
||||
THEN:
|
||||
- The user is redirected to login instead of a 500 error
|
||||
"""
|
||||
doc = DocumentFactory.create(filename="missing-original.pdf")
|
||||
share_link = ShareLink.objects.create(
|
||||
slug="missingfilelink",
|
||||
document=doc,
|
||||
file_version=ShareLink.FileVersion.ORIGINAL,
|
||||
)
|
||||
|
||||
self.client.logout()
|
||||
response = self.client.get(f"/share/{share_link.slug}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||
|
||||
def test_download_ready_bundle_missing_file_returns_503(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A READY bundle whose zip file is missing from disk
|
||||
WHEN:
|
||||
- The public share link is requested anonymously
|
||||
THEN:
|
||||
- A 503 is returned instead of a 500 error
|
||||
"""
|
||||
bundle = ShareLinkBundle.objects.create(
|
||||
slug="missingbundlefile",
|
||||
file_version=ShareLink.FileVersion.ARCHIVE,
|
||||
status=ShareLinkBundle.Status.READY,
|
||||
file_path="bundles/gone.zip",
|
||||
)
|
||||
bundle.documents.set([self.document])
|
||||
|
||||
self.client.logout()
|
||||
response = self.client.get(f"/share/{bundle.slug}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
|
||||
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
+26
-9
@@ -577,13 +577,19 @@ class CorrespondentViewSet(
|
||||
def list(self, request, *args, **kwargs):
|
||||
if request.query_params.get("last_correspondence", None):
|
||||
self.queryset = self.queryset.annotate(
|
||||
last_correspondence=Max("documents__created"),
|
||||
last_correspondence=Max(
|
||||
"documents__created",
|
||||
filter=self.get_document_count_filter(),
|
||||
),
|
||||
)
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
self.queryset = self.queryset.annotate(
|
||||
last_correspondence=Max("documents__created"),
|
||||
last_correspondence=Max(
|
||||
"documents__created",
|
||||
filter=self.get_document_count_filter(),
|
||||
),
|
||||
)
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
@@ -4573,6 +4579,10 @@ class ShareLinkViewSet(
|
||||
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
model = ShareLinkBundle
|
||||
|
||||
# Bundles are immutable once created; rebuild via the dedicated action
|
||||
# rather than PUT/PATCH.
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
|
||||
queryset = ShareLinkBundle.objects.all()
|
||||
|
||||
serializer_class = ShareLinkBundleSerializer
|
||||
@@ -4707,12 +4717,15 @@ class SharedLinkView(View):
|
||||
and share_link.expiration < timezone.now()
|
||||
):
|
||||
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
||||
return serve_file(
|
||||
doc=share_link.document,
|
||||
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
|
||||
and share_link.document.has_archive_version,
|
||||
disposition="inline",
|
||||
)
|
||||
try:
|
||||
return serve_file(
|
||||
doc=share_link.document,
|
||||
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
|
||||
and share_link.document.has_archive_version,
|
||||
disposition="inline",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
|
||||
|
||||
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
|
||||
if bundle is None:
|
||||
@@ -4734,7 +4747,11 @@ class SharedLinkView(View):
|
||||
|
||||
file_path = bundle.absolute_file_path
|
||||
|
||||
if bundle.status == ShareLinkBundle.Status.FAILED or file_path is None:
|
||||
if (
|
||||
bundle.status == ShareLinkBundle.Status.FAILED
|
||||
or file_path is None
|
||||
or not file_path.exists()
|
||||
):
|
||||
return HttpResponse(
|
||||
_(
|
||||
"The share link bundle is unavailable.",
|
||||
|
||||
@@ -295,7 +295,7 @@ urlpatterns = [
|
||||
],
|
||||
),
|
||||
),
|
||||
re_path(r"share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
|
||||
re_path(r"^share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
|
||||
re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"),
|
||||
re_path(r"admin/", admin.site.urls),
|
||||
re_path(
|
||||
|
||||
Reference in New Issue
Block a user