Compare commits

..
Author SHA1 Message Date
Trenton HolmesandClaude Sonnet 5 f5c3a9cfe0 Fix: reject non-dict user_args/barcode_tag_mapping in config API
JSONField(binary=True) accepts any JSON value, so a truthy non-dict
(bool/int/list/string) silently passed validation and later crashed
tesseract.py's OCR arg merge or barcodes.py's Barcode.is_tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 13:31:39 -07:00
6 changed files with 74 additions and 134 deletions
+4 -6
View File
@@ -2037,10 +2037,10 @@ class BulkEditSerializer(
raise serializers.ValidationError("remove_custom_fields not specified")
def _validate_owner(self, owner):
try:
return User.objects.get(pk=owner)
except (User.DoesNotExist, TypeError, ValueError):
ownerUser = User.objects.get(pk=owner)
if ownerUser is None:
raise serializers.ValidationError("Specified owner cannot be found")
return ownerUser
def _validate_parameters_set_permissions(self, parameters) -> None:
if "set_permissions" not in parameters:
@@ -2060,7 +2060,7 @@ class BulkEditSerializer(
or not float(parameters["degrees"]).is_integer()
):
raise serializers.ValidationError("invalid rotation degrees")
except (TypeError, ValueError):
except ValueError:
raise serializers.ValidationError("invalid rotation degrees")
def _validate_source_mode(self, parameters) -> None:
@@ -2073,8 +2073,6 @@ class BulkEditSerializer(
def _validate_parameters_split(self, parameters) -> None:
if "pages" not in parameters:
raise serializers.ValidationError("pages not specified")
if not isinstance(parameters["pages"], str):
raise serializers.ValidationError("invalid pages specified")
try:
pages = []
docs = parameters["pages"].split(",")
+6 -11
View File
@@ -1189,18 +1189,13 @@ def before_task_publish_handler(
trigger_source = _determine_trigger_source(headers)
owner_id = _extract_owner_id(task_type, task_kwargs)
# A retried task is republished with the same task_id, so this fires
# again for it; get_or_create keeps the original PENDING record
# instead of raising a duplicate-key IntegrityError on the retry.
PaperlessTask.objects.get_or_create(
PaperlessTask.objects.create(
task_id=task_id,
defaults={
"task_type": task_type,
"trigger_source": trigger_source,
"status": PaperlessTask.Status.PENDING,
"input_data": input_data,
"owner_id": owner_id,
},
task_type=task_type,
trigger_source=trigger_source,
status=PaperlessTask.Status.PENDING,
input_data=input_data,
owner_id=owner_id,
)
except Exception: # pragma: no cover
logger.exception("Creating PaperlessTask failed")
@@ -194,6 +194,56 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
self.assertEqual(config.language, None)
self.assertEqual(config.barcode_tag_mapping, None)
def test_api_update_config_rejects_non_dict_user_args(self) -> None:
"""
GIVEN:
- API request to update app config with a JSON-encoded non-dict
value (e.g. a bare string) for the user_args JSONField
WHEN:
- API is called
THEN:
- Request is rejected with a 400, not silently accepted
- Config is not updated
"""
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"user_args": json.dumps("not a dict"),
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
config = ApplicationConfiguration.objects.first()
assert config is not None
self.assertEqual(config.user_args, None)
def test_api_update_config_rejects_non_dict_barcode_tag_mapping(self) -> None:
"""
GIVEN:
- API request to update app config with a JSON-encoded non-dict
value (e.g. a bare list) for the barcode_tag_mapping JSONField
WHEN:
- API is called
THEN:
- Request is rejected with a 400, not silently accepted
- Config is not updated
"""
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"barcode_tag_mapping": json.dumps([1, 2, 3]),
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
config = ApplicationConfiguration.objects.first()
assert config is not None
self.assertEqual(config.barcode_tag_mapping, None)
def test_api_replace_app_logo(self) -> None:
"""
GIVEN:
-106
View File
@@ -1165,65 +1165,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"set_permissions not specified", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_nonexistent_owner(self, m) -> None:
"""
BulkEditSerializer._validate_owner called User.objects.get(pk=owner)
with no try/except, so a syntactically valid but nonexistent user
id raised an uncaught User.DoesNotExist instead of a clean 400.
"""
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {"view": {"users": [self.user.id]}},
"owner": 999999,
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_wrong_typed_owner(self, m) -> None:
"""
_validate_owner only caught User.DoesNotExist -- a wrong-typed
owner (list/dict/non-numeric string) reaches
User.objects.get(pk=owner) and raises an uncaught TypeError or
ValueError instead, since `parameters` is a bare DictField with
no type checking on "owner" at that level.
"""
self.setup_mock(m, "set_permissions")
for bad_owner in (["not", "an", "id"], {"nested": "dict"}, "not-a-number"):
with self.subTest(owner=bad_owner):
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {
"view": {"users": [self.user.id]},
},
"owner": bad_owner,
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m) -> None:
self.setup_mock(m, "set_permissions")
@@ -1497,53 +1438,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.rotate")
def test_bulk_edit_rotate_rejects_null_degrees(self, m) -> None:
"""
BulkEditSerializer._validate_parameters_rotate's
`float(parameters["degrees"])` raised an uncaught TypeError for
None (only ValueError was caught), reachable via the legacy
generic /api/documents/bulk_edit/ method="rotate" path (the
dedicated /api/documents/rotate/ endpoint isn't affected, its
`degrees` field is a typed IntegerField).
"""
self.setup_mock(m, "rotate")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "rotate",
"parameters": {"degrees": None},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.split")
def test_bulk_edit_split_rejects_null_pages(self, m) -> None:
"""
BulkEditSerializer._validate_parameters_split called
parameters["pages"].split(",") with no type check, so a null
value raised an uncaught AttributeError instead of a clean 400.
"""
self.setup_mock(m, "split")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "split",
"parameters": {"pages": None},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.rotate")
def test_rotate_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin")
-11
View File
@@ -106,17 +106,6 @@ class TestBeforeTaskPublishHandler:
assert task.task_type == PaperlessTask.TaskType.TRAIN_CLASSIFIER
assert task.trigger_source == PaperlessTask.TriggerSource.MANUAL
# A Celery retry republishes with the same task_id; this must not
# raise a duplicate-key IntegrityError, and must leave the original
# PENDING record alone.
send_publish(
"documents.tasks.train_classifier",
(),
{},
headers={"id": task_id},
)
assert PaperlessTask.objects.filter(task_id=task_id).count() == 1
def test_creates_task_for_sanity_check(self) -> None:
task_id = send_publish("documents.tasks.sanity_check", (), {})
task = PaperlessTask.objects.get(task_id=task_id)
+14
View File
@@ -235,6 +235,20 @@ class ApplicationConfigurationSerializer(
) -> list[str]:
return sorted(name for name in os.environ if name.startswith("PAPERLESS_"))
def validate_user_args(self, value):
if value is not None and not isinstance(value, dict):
raise serializers.ValidationError(
"user_args must be a JSON object.",
)
return value
def validate_barcode_tag_mapping(self, value):
if value is not None and not isinstance(value, dict):
raise serializers.ValidationError(
"barcode_tag_mapping must be a JSON object.",
)
return value
def run_validation(self, data):
# Empty strings treated as None to avoid unexpected behavior
if "user_args" in data and data["user_args"] == "":