Merge commit from fork

* 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
This commit is contained in:
shamoon
2025-08-16 07:34:00 -07:00
committed by GitHub
parent 42bdbc1b2d
commit b1c406680f
9 changed files with 179 additions and 10 deletions

View 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) }}
}

View File

@@ -53,6 +53,8 @@ export interface ManagementListColumn {
rendersHtml?: boolean
hideOnMobile?: boolean
monospace?: boolean
}
@Directive()

View 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 ? '...' : ''}`
},
},
]

View File

@@ -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>

After

Width:  |  Height:  |  Size: 140 B

View 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:

View 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()

View 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__"

View 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/",

102
src/paperless/validators.py Normal file
View File

@@ -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}")