mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-06 01:56:14 -05:00
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:
@@ -68,6 +68,8 @@
|
|||||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||||
@if (column.rendersHtml) {
|
@if (column.rendersHtml) {
|
||||||
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
||||||
|
} @else if (column.monospace) {
|
||||||
|
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
|
||||||
} @else {
|
} @else {
|
||||||
{{ column.valueFn.call(null, object) }}
|
{{ column.valueFn.call(null, object) }}
|
||||||
}
|
}
|
||||||
|
@@ -53,6 +53,8 @@ export interface ManagementListColumn {
|
|||||||
rendersHtml?: boolean
|
rendersHtml?: boolean
|
||||||
|
|
||||||
hideOnMobile?: boolean
|
hideOnMobile?: boolean
|
||||||
|
|
||||||
|
monospace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
|
@@ -48,10 +48,10 @@ export class StoragePathListComponent extends ManagementListComponent<StoragePat
|
|||||||
{
|
{
|
||||||
key: 'path',
|
key: 'path',
|
||||||
name: $localize`Path`,
|
name: $localize`Path`,
|
||||||
rendersHtml: true,
|
|
||||||
hideOnMobile: true,
|
hideOnMobile: true,
|
||||||
|
monospace: true,
|
||||||
valueFn: (c: StoragePath) => {
|
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 ? '...' : ''}`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
4
src/documents/tests/samples/malicious.svg
Normal file
4
src/documents/tests/samples/malicious.svg
Normal 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 |
@@ -149,6 +149,11 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- old app_logo file is deleted
|
- 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:
|
with (Path(__file__).parent / "samples" / "simple.jpg").open("rb") as f:
|
||||||
self.client.patch(
|
self.client.patch(
|
||||||
f"{self.ENDPOINT}1/",
|
f"{self.ENDPOINT}1/",
|
||||||
@@ -156,6 +161,12 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
"app_logo": f,
|
"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()
|
config = ApplicationConfiguration.objects.first()
|
||||||
old_logo = config.app_logo
|
old_logo = config.app_logo
|
||||||
self.assertTrue(Path(old_logo.path).exists())
|
self.assertTrue(Path(old_logo.path).exists())
|
||||||
@@ -168,6 +179,26 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(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):
|
def test_create_not_allowed(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
@@ -13,6 +13,7 @@ from urllib.parse import quote
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import magic
|
||||||
import pathvalidate
|
import pathvalidate
|
||||||
from celery import states
|
from celery import states
|
||||||
from django.conf import settings
|
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 Length
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.db.models.manager import Manager
|
from django.db.models.manager import Manager
|
||||||
|
from django.http import FileResponse
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.http import HttpResponseBadRequest
|
from django.http import HttpResponseBadRequest
|
||||||
@@ -173,6 +175,7 @@ from paperless import version
|
|||||||
from paperless.celery import app as celery_app
|
from paperless.celery import app as celery_app
|
||||||
from paperless.config import GeneralConfig
|
from paperless.config import GeneralConfig
|
||||||
from paperless.db import GnuPG
|
from paperless.db import GnuPG
|
||||||
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.serialisers import GroupSerializer
|
from paperless.serialisers import GroupSerializer
|
||||||
from paperless.serialisers import UserSerializer
|
from paperless.serialisers import UserSerializer
|
||||||
from paperless.views import StandardPagination
|
from paperless.views import StandardPagination
|
||||||
@@ -2946,3 +2949,27 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
doc_ids = [doc.id for doc in docs]
|
doc_ids = [doc.id for doc in docs]
|
||||||
empty_trash(doc_ids=doc_ids)
|
empty_trash(doc_ids=doc_ids)
|
||||||
return Response({"result": "OK", "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()
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import magic
|
||||||
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.mfa.totp.internal.auth import TOTP
|
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 rest_framework.authtoken.serializers import AuthTokenSerializer
|
||||||
|
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
|
from paperless.validators import reject_dangerous_svg
|
||||||
from paperless_mail.serialisers import ObfuscatedPasswordField
|
from paperless_mail.serialisers import ObfuscatedPasswordField
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.settings")
|
logger = logging.getLogger("paperless.settings")
|
||||||
@@ -206,6 +208,11 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer):
|
|||||||
instance.app_logo.delete()
|
instance.app_logo.delete()
|
||||||
return super().update(instance, validated_data)
|
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:
|
class Meta:
|
||||||
model = ApplicationConfiguration
|
model = ApplicationConfiguration
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
@@ -1,5 +1,3 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from allauth.account import views as allauth_account_views
|
from allauth.account import views as allauth_account_views
|
||||||
from allauth.mfa.base import views as allauth_mfa_views
|
from allauth.mfa.base import views as allauth_mfa_views
|
||||||
from allauth.socialaccount import views as allauth_social_account_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.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
from django.views.static import serve
|
|
||||||
from drf_spectacular.views import SpectacularAPIView
|
from drf_spectacular.views import SpectacularAPIView
|
||||||
from drf_spectacular.views import SpectacularSwaggerView
|
from drf_spectacular.views import SpectacularSwaggerView
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
@@ -45,6 +42,7 @@ from documents.views import UnifiedSearchViewSet
|
|||||||
from documents.views import WorkflowActionViewSet
|
from documents.views import WorkflowActionViewSet
|
||||||
from documents.views import WorkflowTriggerViewSet
|
from documents.views import WorkflowTriggerViewSet
|
||||||
from documents.views import WorkflowViewSet
|
from documents.views import WorkflowViewSet
|
||||||
|
from documents.views import serve_logo
|
||||||
from paperless.consumers import StatusConsumer
|
from paperless.consumers import StatusConsumer
|
||||||
from paperless.views import ApplicationConfigurationViewSet
|
from paperless.views import ApplicationConfigurationViewSet
|
||||||
from paperless.views import DisconnectSocialAccountView
|
from paperless.views import DisconnectSocialAccountView
|
||||||
@@ -267,11 +265,7 @@ urlpatterns = [
|
|||||||
# TODO: with localization, this is even worse! :/
|
# TODO: with localization, this is even worse! :/
|
||||||
),
|
),
|
||||||
# App logo
|
# App logo
|
||||||
re_path(
|
path("logo/<path:filename>", serve_logo, name="app_logo"),
|
||||||
r"^logo(?P<path>.*)$",
|
|
||||||
serve,
|
|
||||||
kwargs={"document_root": Path(settings.MEDIA_ROOT) / "logo"},
|
|
||||||
),
|
|
||||||
# allauth
|
# allauth
|
||||||
path(
|
path(
|
||||||
"accounts/",
|
"accounts/",
|
||||||
|
102
src/paperless/validators.py
Normal file
102
src/paperless/validators.py
Normal 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}")
|
Reference in New Issue
Block a user