]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Merge commit from fork
authorshamoon <4887959+shamoon@users.noreply.github.com>
Sat, 16 Aug 2025 14:34:00 +0000 (07:34 -0700)
committerGitHub <noreply@github.com>
Sat, 16 Aug 2025 14:34:00 +0000 (07:34 -0700)
* Security: prevent XSS with storage path template rendering

* Security: prevent XSS svg uploads

* Security: force attachment disposition for logo

* Add suggestions from code review

* Improve SVG validation with allowlist for tags and attributes

src-ui/src/app/components/manage/management-list/management-list.component.html
src-ui/src/app/components/manage/management-list/management-list.component.ts
src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts
src/documents/tests/samples/malicious.svg [new file with mode: 0644]
src/documents/tests/test_api_app_config.py
src/documents/views.py
src/paperless/serialisers.py
src/paperless/urls.py
src/paperless/validators.py [new file with mode: 0644]

index 6375a36673c414430b4db210d26b67d19739fdd8..7e8f46511b8bbd03013091595b70ffa2331ef345 100644 (file)
@@ -68,6 +68,8 @@
             <td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
               @if (column.rendersHtml) {
                 <div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
+              } @else if (column.monospace) {
+                <span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
               } @else {
                 {{ column.valueFn.call(null, object) }}
               }
index 670de2699e6136caf796b07d3476c616784e9547..075a909a33cdf6db5e9a0ac2a7893f5e41d02465 100644 (file)
@@ -53,6 +53,8 @@ export interface ManagementListColumn {
   rendersHtml?: boolean
 
   hideOnMobile?: boolean
+
+  monospace?: boolean
 }
 
 @Directive()
index f14ba9aa38f71c01dc57d0427d5fbb744dc8a2c6..5cab89befc52df2861a680465e5ac83ece4ae684 100644 (file)
@@ -48,10 +48,10 @@ export class StoragePathListComponent extends ManagementListComponent<StoragePat
       {
         key: 'path',
         name: $localize`Path`,
-        rendersHtml: true,
         hideOnMobile: true,
+        monospace: true,
         valueFn: (c: StoragePath) => {
-          return `<code>${c.path?.slice(0, 49)}${c.path?.length > 50 ? '...' : ''}</code>`
+          return `${c.path?.slice(0, 49)}${c.path?.length > 50 ? '...' : ''}`
         },
       },
     ]
diff --git a/src/documents/tests/samples/malicious.svg b/src/documents/tests/samples/malicious.svg
new file mode 100644 (file)
index 0000000..11fb658
--- /dev/null
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
+  <text x="10" y="20">Hello</text>
+  <script>alert('XSS')</script>
+</svg>
index 5968b16701f2b63b8286f85532457c1a85862ef2..b43d312b7f52df3c62720cb5441199e0ecc7abbd 100644 (file)
@@ -149,6 +149,11 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
         THEN:
             - old app_logo file is deleted
         """
+        admin = User.objects.create_superuser(username="admin")
+        self.client.force_login(user=admin)
+        response = self.client.get("/logo/")
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
         with (Path(__file__).parent / "samples" / "simple.jpg").open("rb") as f:
             self.client.patch(
                 f"{self.ENDPOINT}1/",
@@ -156,6 +161,12 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
                     "app_logo": f,
                 },
             )
+
+        # Logo exists at /logo/simple.jpg
+        response = self.client.get("/logo/simple.jpg")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertIn("image/jpeg", response["Content-Type"])
+
         config = ApplicationConfiguration.objects.first()
         old_logo = config.app_logo
         self.assertTrue(Path(old_logo.path).exists())
@@ -168,6 +179,26 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
             )
         self.assertFalse(Path(old_logo.path).exists())
 
+    def test_api_rejects_malicious_svg_logo(self):
+        """
+        GIVEN:
+            - An SVG logo containing a <script> tag
+        WHEN:
+            - Uploaded via PATCH to app config
+        THEN:
+            - SVG is rejected with 400
+        """
+        path = Path(__file__).parent / "samples" / "malicious.svg"
+        with path.open("rb") as f:
+            response = self.client.patch(
+                f"{self.ENDPOINT}1/",
+                {"app_logo": f},
+                format="multipart",
+            )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn("disallowed", str(response.data).lower())
+
     def test_create_not_allowed(self):
         """
         GIVEN:
index c98df6da4d70195462b526725245ea191af548dc..7f004901f920b88007eba9dac9705257f9f11c92 100644 (file)
@@ -13,6 +13,7 @@ from urllib.parse import quote
 from urllib.parse import urlparse
 
 import httpx
+import magic
 import pathvalidate
 from celery import states
 from django.conf import settings
@@ -32,6 +33,7 @@ from django.db.models import When
 from django.db.models.functions import Length
 from django.db.models.functions import Lower
 from django.db.models.manager import Manager
+from django.http import FileResponse
 from django.http import Http404
 from django.http import HttpResponse
 from django.http import HttpResponseBadRequest
@@ -173,6 +175,7 @@ from paperless import version
 from paperless.celery import app as celery_app
 from paperless.config import GeneralConfig
 from paperless.db import GnuPG
+from paperless.models import ApplicationConfiguration
 from paperless.serialisers import GroupSerializer
 from paperless.serialisers import UserSerializer
 from paperless.views import StandardPagination
@@ -2946,3 +2949,27 @@ class TrashView(ListModelMixin, PassUserMixin):
                 doc_ids = [doc.id for doc in docs]
             empty_trash(doc_ids=doc_ids)
         return Response({"result": "OK", "doc_ids": doc_ids})
+
+
+def serve_logo(request, filename):
+    """
+    Serves the configured logo file with Content-Disposition: attachment.
+    Prevents inline execution of SVGs. See GHSA-6p53-hqqw-8j62
+    """
+    logger.warning("Serving app logo...")
+    config = ApplicationConfiguration.objects.first()
+    app_logo = config.app_logo
+
+    logger.warning(f"Serving logo: {app_logo}")
+
+    if not app_logo:
+        raise Http404("No logo configured")
+
+    path = app_logo.path
+    content_type = magic.from_file(path, mime=True) or "application/octet-stream"
+
+    return FileResponse(
+        app_logo.open("rb"),
+        content_type=content_type,
+        filename=app_logo.name,
+    ).as_attachment()
index 9943a76ee2bee1541ab095d2bd8b5c5989e59763..3398d53d1f35a1b4b9cd2e30f1a220db97cd8e1a 100644 (file)
@@ -1,5 +1,6 @@
 import logging
 
+import magic
 from allauth.mfa.adapter import get_adapter as get_mfa_adapter
 from allauth.mfa.models import Authenticator
 from allauth.mfa.totp.internal.auth import TOTP
@@ -12,6 +13,7 @@ from rest_framework import serializers
 from rest_framework.authtoken.serializers import AuthTokenSerializer
 
 from paperless.models import ApplicationConfiguration
+from paperless.validators import reject_dangerous_svg
 from paperless_mail.serialisers import ObfuscatedPasswordField
 
 logger = logging.getLogger("paperless.settings")
@@ -206,6 +208,11 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer):
             instance.app_logo.delete()
         return super().update(instance, validated_data)
 
+    def validate_app_logo(self, file):
+        if magic.from_buffer(file.read(2048), mime=True) == "image/svg+xml":
+            reject_dangerous_svg(file)
+        return file
+
     class Meta:
         model = ApplicationConfiguration
         fields = "__all__"
index 007d833555ce2f8baa0f321330c41144310ccee7..e8c92505c225d6265fe42cee12f418792ea62ddc 100644 (file)
@@ -1,5 +1,3 @@
-from pathlib import Path
-
 from allauth.account import views as allauth_account_views
 from allauth.mfa.base import views as allauth_mfa_views
 from allauth.socialaccount import views as allauth_social_account_views
@@ -13,7 +11,6 @@ from django.urls import re_path
 from django.utils.translation import gettext_lazy as _
 from django.views.decorators.csrf import ensure_csrf_cookie
 from django.views.generic import RedirectView
-from django.views.static import serve
 from drf_spectacular.views import SpectacularAPIView
 from drf_spectacular.views import SpectacularSwaggerView
 from rest_framework.routers import DefaultRouter
@@ -45,6 +42,7 @@ from documents.views import UnifiedSearchViewSet
 from documents.views import WorkflowActionViewSet
 from documents.views import WorkflowTriggerViewSet
 from documents.views import WorkflowViewSet
+from documents.views import serve_logo
 from paperless.consumers import StatusConsumer
 from paperless.views import ApplicationConfigurationViewSet
 from paperless.views import DisconnectSocialAccountView
@@ -267,11 +265,7 @@ urlpatterns = [
         # TODO: with localization, this is even worse! :/
     ),
     # App logo
-    re_path(
-        r"^logo(?P<path>.*)$",
-        serve,
-        kwargs={"document_root": Path(settings.MEDIA_ROOT) / "logo"},
-    ),
+    path("logo/<path:filename>", serve_logo, name="app_logo"),
     # allauth
     path(
         "accounts/",
diff --git a/src/paperless/validators.py b/src/paperless/validators.py
new file mode 100644 (file)
index 0000000..dea1f51
--- /dev/null
@@ -0,0 +1,102 @@
+from django.core.exceptions import ValidationError
+from lxml import etree
+
+ALLOWED_SVG_TAGS: set[str] = {
+    "svg",
+    "g",
+    "path",
+    "rect",
+    "circle",
+    "ellipse",
+    "line",
+    "polyline",
+    "polygon",
+    "text",
+    "tspan",
+    "defs",
+    "linearGradient",
+    "radialGradient",
+    "stop",
+    "clipPath",
+    "use",
+    "title",
+    "desc",
+}
+
+ALLOWED_SVG_ATTRIBUTES: set[str] = {
+    "id",
+    "class",
+    "style",
+    "d",
+    "fill",
+    "fill-rule",
+    "stroke",
+    "stroke-width",
+    "stroke-linecap",
+    "stroke-linejoin",
+    "stroke-miterlimit",
+    "stroke-dasharray",
+    "stroke-dashoffset",
+    "stroke-opacity",
+    "transform",
+    "x",
+    "y",
+    "cx",
+    "cy",
+    "r",
+    "rx",
+    "ry",
+    "width",
+    "height",
+    "x1",
+    "y1",
+    "x2",
+    "y2",
+    "gradientTransform",
+    "gradientUnits",
+    "offset",
+    "stop-color",
+    "stop-opacity",
+    "clip-path",
+    "viewBox",
+    "preserveAspectRatio",
+    "href",
+    "xlink:href",
+    "font-family",
+    "font-size",
+    "font-weight",
+    "text-anchor",
+    "xmlns",
+    "xmlns:xlink",
+}
+
+
+def reject_dangerous_svg(file):
+    """
+    Rejects SVG files that contain dangerous tags or attributes.
+    Raises ValidationError if unsafe content is found.
+    See GHSA-6p53-hqqw-8j62
+    """
+    try:
+        parser = etree.XMLParser(resolve_entities=False)
+        file.seek(0)
+        tree = etree.parse(file, parser)
+        root = tree.getroot()
+    except etree.XMLSyntaxError:
+        raise ValidationError("Invalid SVG file.")
+
+    for element in root.iter():
+        tag = etree.QName(element.tag).localname.lower()
+        if tag not in ALLOWED_SVG_TAGS:
+            raise ValidationError(f"Disallowed SVG tag: <{tag}>")
+
+        for attr_name, attr_value in element.attrib.items():
+            attr_name_lower = attr_name.lower()
+            if attr_name_lower not in ALLOWED_SVG_ATTRIBUTES:
+                raise ValidationError(f"Disallowed SVG attribute: {attr_name}")
+
+            if attr_name_lower in {
+                "href",
+                "xlink:href",
+            } and attr_value.strip().lower().startswith("javascript:"):
+                raise ValidationError(f"Disallowed javascript: URI in {attr_name}")