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:
Trenton H
2026-09-01 19:53:28 +00:00
committed by GitHub
parent ae70b8d60f
commit f993462973
6 changed files with 100 additions and 12 deletions
+27
View File
@@ -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
View File
@@ -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.",
+1 -1
View File
@@ -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(