mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-22 00:52:42 -05:00
Compare commits
3 Commits
dev
...
fix-strip-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d6cfd87cc0 | ||
![]() |
7a287e7479 | ||
![]() |
76a81adcb5 |
1
.github/workflows/repo-maintenance.yml
vendored
1
.github/workflows/repo-maintenance.yml
vendored
@@ -241,7 +241,6 @@ jobs:
|
|||||||
) {
|
) {
|
||||||
nodes {
|
nodes {
|
||||||
id,
|
id,
|
||||||
createdAt,
|
|
||||||
number,
|
number,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
upvoteCount,
|
upvoteCount,
|
||||||
|
@@ -135,7 +135,7 @@ community members. That said, in an effort to keep the repository organized and
|
|||||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
||||||
- Discussions with a marked answer will be automatically closed.
|
- Discussions with a marked answer will be automatically closed.
|
||||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
||||||
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years.
|
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
||||||
|
|
||||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
||||||
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
||||||
|
@@ -1759,11 +1759,6 @@ started by the container.
|
|||||||
|
|
||||||
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
The logo file will be viewable by anyone with access to the Paperless instance login page,
|
|
||||||
so consider your choice of logo carefully and removing exif data from images before uploading.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
@@ -71,20 +71,4 @@ describe('TagListComponent', () => {
|
|||||||
'Do you really want to delete the tag "Tag1"?'
|
'Do you really want to delete the tag "Tag1"?'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should filter out child tags if name filter is empty, otherwise show all', () => {
|
|
||||||
const tags = [
|
|
||||||
{ id: 1, name: 'Tag1', parent: null },
|
|
||||||
{ id: 2, name: 'Tag2', parent: 1 },
|
|
||||||
{ id: 3, name: 'Tag3', parent: null },
|
|
||||||
]
|
|
||||||
component['_nameFilter'] = null // Simulate empty name filter
|
|
||||||
const filtered = component.filterData(tags as any)
|
|
||||||
expect(filtered.length).toBe(2)
|
|
||||||
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
|
|
||||||
|
|
||||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
|
||||||
const filteredWithName = component.filterData(tags as any)
|
|
||||||
expect(filteredWithName.length).toBe(3)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
@@ -62,8 +62,6 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filterData(data: Tag[]) {
|
filterData(data: Tag[]) {
|
||||||
return this.nameFilter?.length
|
return data.filter((tag) => !tag.parent)
|
||||||
? [...data]
|
|
||||||
: data.filter((tag) => !tag.parent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -82,13 +82,6 @@ def _is_ignored(filepath: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _consume(filepath: Path) -> None:
|
def _consume(filepath: Path) -> None:
|
||||||
# Check permissions early
|
|
||||||
try:
|
|
||||||
filepath.stat()
|
|
||||||
except (PermissionError, OSError):
|
|
||||||
logger.warning(f"Not consuming file {filepath}: Permission denied.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if filepath.is_dir() or _is_ignored(filepath):
|
if filepath.is_dir() or _is_ignored(filepath):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -330,12 +323,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Also make sure the file exists still, some scanners might write a
|
# Also make sure the file exists still, some scanners might write a
|
||||||
# temporary file first
|
# temporary file first
|
||||||
try:
|
file_still_exists = filepath.exists() and filepath.is_file()
|
||||||
file_still_exists = filepath.exists() and filepath.is_file()
|
|
||||||
except (PermissionError, OSError): # pragma: no cover
|
|
||||||
# If we can't check, let it fail in the _consume function
|
|
||||||
file_still_exists = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if waited_long_enough and file_still_exists:
|
if waited_long_enough and file_still_exists:
|
||||||
_consume(filepath)
|
_consume(filepath)
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
from fractions import Fraction
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@@ -6,6 +8,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ModuleNotFoundError: # pragma: no cover - Pillow is required in production
|
||||||
|
Image = None # type: ignore[assignment]
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.models import ColorConvertChoices
|
from paperless.models import ColorConvertChoices
|
||||||
@@ -190,6 +197,74 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(Path(old_logo.path).exists())
|
self.assertFalse(Path(old_logo.path).exists())
|
||||||
|
|
||||||
|
def test_api_strips_metadata_from_logo_upload(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An image file containing EXIF metadata including GPS coordinates
|
||||||
|
WHEN:
|
||||||
|
- Uploaded via PATCH to app config
|
||||||
|
THEN:
|
||||||
|
- Stored logo no longer contains EXIF metadata
|
||||||
|
"""
|
||||||
|
if Image is None:
|
||||||
|
self.skipTest("Pillow is not installed")
|
||||||
|
|
||||||
|
if not hasattr(Image, "Exif"):
|
||||||
|
self.skipTest("Current Pillow version cannot create EXIF metadata")
|
||||||
|
|
||||||
|
assert Image is not None
|
||||||
|
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[0x010E] = "Test description" # ImageDescription
|
||||||
|
exif[0x8825] = {
|
||||||
|
1: "N", # GPSLatitudeRef
|
||||||
|
2: (Fraction(51, 1), Fraction(30, 1), Fraction(0, 1)),
|
||||||
|
3: "E", # GPSLongitudeRef
|
||||||
|
4: (Fraction(0, 1), Fraction(7, 1), Fraction(0, 1)),
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
Image.new("RGB", (8, 8), "white").save(buffer, format="JPEG", exif=exif)
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
with Image.open(BytesIO(buffer.getvalue())) as uploaded_image:
|
||||||
|
self.assertGreater(len(uploaded_image.getexif()), 0)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
{
|
||||||
|
"app_logo": SimpleUploadedFile(
|
||||||
|
name="with_exif.jpg",
|
||||||
|
content=buffer.getvalue(),
|
||||||
|
content_type="image/jpeg",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
config = ApplicationConfiguration.objects.first()
|
||||||
|
stored_logo = Path(config.app_logo.path)
|
||||||
|
self.assertTrue(stored_logo.exists())
|
||||||
|
|
||||||
|
with Image.open(stored_logo) as sanitized:
|
||||||
|
sanitized_exif = sanitized.getexif()
|
||||||
|
self.assertNotEqual(sanitized_exif.get(0x010E), "Test description")
|
||||||
|
|
||||||
|
gps_ifd = None
|
||||||
|
if hasattr(sanitized_exif, "get_ifd"):
|
||||||
|
try:
|
||||||
|
gps_ifd = sanitized_exif.get_ifd(0x8825)
|
||||||
|
except KeyError:
|
||||||
|
gps_ifd = None
|
||||||
|
else:
|
||||||
|
gps_ifd = sanitized_exif.get(0x8825)
|
||||||
|
|
||||||
|
if gps_ifd is not None:
|
||||||
|
self.assertEqual(len(gps_ifd), 0, "GPS metadata should be cleared")
|
||||||
|
|
||||||
|
self.assertNotIn("exif", sanitized.info)
|
||||||
|
|
||||||
def test_api_rejects_malicious_svg_logo(self):
|
def test_api_rejects_malicious_svg_logo(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
@@ -209,26 +209,6 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
|
|||||||
# assert that we have an error logged with this invalid file.
|
# assert that we have an error logged with this invalid file.
|
||||||
error_logger.assert_called_once()
|
error_logger.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.management.commands.document_consumer.logger.warning")
|
|
||||||
def test_permission_error_on_prechecks(self, warning_logger):
|
|
||||||
filepath = Path(self.dirs.consumption_dir) / "selinux.txt"
|
|
||||||
filepath.touch()
|
|
||||||
|
|
||||||
original_stat = Path.stat
|
|
||||||
|
|
||||||
def raising_stat(self, *args, **kwargs):
|
|
||||||
if self == filepath:
|
|
||||||
raise PermissionError("Permission denied")
|
|
||||||
return original_stat(self, *args, **kwargs)
|
|
||||||
|
|
||||||
with mock.patch("pathlib.Path.stat", new=raising_stat):
|
|
||||||
document_consumer._consume(filepath)
|
|
||||||
|
|
||||||
warning_logger.assert_called_once()
|
|
||||||
(args, _) = warning_logger.call_args
|
|
||||||
self.assertIn("Permission denied", args[0])
|
|
||||||
self.consume_file_mock.assert_not_called()
|
|
||||||
|
|
||||||
@override_settings(CONSUMPTION_DIR="does_not_exist")
|
@override_settings(CONSUMPTION_DIR="does_not_exist")
|
||||||
def test_consumption_directory_invalid(self):
|
def test_consumption_directory_invalid(self):
|
||||||
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")
|
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
||||||
@@ -9,6 +10,10 @@ from allauth.socialaccount.models import SocialApp
|
|||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from PIL import Image
|
||||||
|
from PIL import ImageOps
|
||||||
|
from PIL import UnidentifiedImageError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.authtoken.serializers import AuthTokenSerializer
|
from rest_framework.authtoken.serializers import AuthTokenSerializer
|
||||||
|
|
||||||
@@ -19,6 +24,102 @@ from paperless_mail.serialisers import ObfuscatedPasswordField
|
|||||||
logger = logging.getLogger("paperless.settings")
|
logger = logging.getLogger("paperless.settings")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_image_metadata(uploaded_file, mime_type: str | None):
|
||||||
|
"""Return a copy of ``uploaded_file`` with EXIF/ICC metadata removed."""
|
||||||
|
|
||||||
|
if uploaded_file is None:
|
||||||
|
return uploaded_file
|
||||||
|
|
||||||
|
original_position = uploaded_file.tell() if hasattr(uploaded_file, "tell") else None
|
||||||
|
image = None
|
||||||
|
|
||||||
|
sanitized = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(uploaded_file, "seek"):
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
image = Image.open(uploaded_file)
|
||||||
|
image.load()
|
||||||
|
except (UnidentifiedImageError, OSError):
|
||||||
|
if hasattr(uploaded_file, "seek") and original_position is not None:
|
||||||
|
uploaded_file.seek(original_position)
|
||||||
|
return uploaded_file
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_format = (image.format or "").upper()
|
||||||
|
image = ImageOps.exif_transpose(image)
|
||||||
|
|
||||||
|
if image_format not in {"JPEG", "JPG", "PNG"}:
|
||||||
|
if hasattr(uploaded_file, "seek") and original_position is not None:
|
||||||
|
uploaded_file.seek(original_position)
|
||||||
|
return uploaded_file
|
||||||
|
|
||||||
|
if hasattr(image, "info"):
|
||||||
|
image.info.pop("exif", None)
|
||||||
|
image.info.pop("icc_profile", None)
|
||||||
|
image.info.pop("comment", None)
|
||||||
|
|
||||||
|
if image_format in {"JPEG", "JPG"}:
|
||||||
|
sanitized = image.convert("RGB")
|
||||||
|
save_kwargs = {
|
||||||
|
"format": "JPEG",
|
||||||
|
"quality": 95,
|
||||||
|
"subsampling": 0,
|
||||||
|
"optimize": True,
|
||||||
|
"exif": b"",
|
||||||
|
}
|
||||||
|
else: # PNG
|
||||||
|
target_mode = (
|
||||||
|
"RGBA"
|
||||||
|
if ("A" in image.mode or image.info.get("transparency"))
|
||||||
|
else "RGB"
|
||||||
|
)
|
||||||
|
sanitized = image.convert(target_mode)
|
||||||
|
save_kwargs = {
|
||||||
|
"format": "PNG",
|
||||||
|
"optimize": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
try:
|
||||||
|
sanitized.save(buffer, **save_kwargs)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
buffer = BytesIO()
|
||||||
|
if image_format in {"JPEG", "JPG"}:
|
||||||
|
sanitized.save(
|
||||||
|
buffer,
|
||||||
|
format="JPEG",
|
||||||
|
quality=90,
|
||||||
|
subsampling=0,
|
||||||
|
exif=b"",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sanitized.save(
|
||||||
|
buffer,
|
||||||
|
format="PNG",
|
||||||
|
)
|
||||||
|
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
if hasattr(uploaded_file, "close"):
|
||||||
|
try:
|
||||||
|
uploaded_file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
content_type = getattr(uploaded_file, "content_type", None) or mime_type
|
||||||
|
return SimpleUploadedFile(
|
||||||
|
name=getattr(uploaded_file, "name", "logo"),
|
||||||
|
content=buffer.getvalue(),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if sanitized is not None:
|
||||||
|
sanitized.close()
|
||||||
|
if image is not None:
|
||||||
|
image.close()
|
||||||
|
|
||||||
|
|
||||||
class PaperlessAuthTokenSerializer(AuthTokenSerializer):
|
class PaperlessAuthTokenSerializer(AuthTokenSerializer):
|
||||||
code = serializers.CharField(
|
code = serializers.CharField(
|
||||||
label="MFA Code",
|
label="MFA Code",
|
||||||
@@ -209,9 +310,22 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer):
|
|||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
def validate_app_logo(self, file):
|
def validate_app_logo(self, file):
|
||||||
if file and magic.from_buffer(file.read(2048), mime=True) == "image/svg+xml":
|
if not file:
|
||||||
|
return file
|
||||||
|
|
||||||
|
if hasattr(file, "seek"):
|
||||||
|
file.seek(0)
|
||||||
|
mime_type = magic.from_buffer(file.read(2048), mime=True)
|
||||||
|
if hasattr(file, "seek"):
|
||||||
|
file.seek(0)
|
||||||
|
|
||||||
|
if mime_type == "image/svg+xml":
|
||||||
reject_dangerous_svg(file)
|
reject_dangerous_svg(file)
|
||||||
return file
|
if hasattr(file, "seek"):
|
||||||
|
file.seek(0)
|
||||||
|
return file
|
||||||
|
|
||||||
|
return strip_image_metadata(file, mime_type)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ApplicationConfiguration
|
model = ApplicationConfiguration
|
||||||
|
Reference in New Issue
Block a user