<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) }}
}
rendersHtml?: boolean
hideOnMobile?: boolean
+
+ monospace?: boolean
}
@Directive()
{
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 ? '...' : ''}`
},
},
]
--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
+ <text x="10" y="20">Hello</text>
+ <script>alert('XSS')</script>
+</svg>
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/",
"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())
)
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:
from urllib.parse import urlparse
import httpx
+import magic
import pathvalidate
from celery import states
from django.conf import settings
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
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
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()
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
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")
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__"
-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
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
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
# 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/",
--- /dev/null
+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}")