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
5 changed files with 112 additions and 240 deletions
+47 -44
View File
@@ -210,9 +210,6 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
return match
PERMISSION_ACTIONS = ("view", "change")
class SetPermissionsMixin:
def _validate_user_ids(self, user_ids):
users = User.objects.none()
@@ -235,9 +232,12 @@ class SetPermissionsMixin:
return groups
def validate_set_permissions(self, set_permissions=None):
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
permissions_dict = {
"view": {},
"change": {},
}
if set_permissions is not None:
for action in PERMISSION_ACTIONS:
for action in ["view", "change"]:
if action in set_permissions:
if "users" in set_permissions[action]:
users = set_permissions[action]["users"]
@@ -265,31 +265,41 @@ class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
super().__init__(*args, **kwargs)
class _PermissionSetSerializer(serializers.Serializer[dict[str, Any]]):
users = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
)
groups = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
)
class SetPermissionsSerializer(serializers.Serializer[dict[str, Any]]):
view = _PermissionSetSerializer(required=False)
change = _PermissionSetSerializer(required=False)
def to_internal_value(self, data):
if isinstance(data, dict):
unknown_keys = set(data) - set(PERMISSION_ACTIONS)
if unknown_keys:
raise serializers.ValidationError(
{key: "Unknown permission action." for key in sorted(unknown_keys)},
)
return super().to_internal_value(data)
@extend_schema_field(
field={
"type": "object",
"properties": {
"view": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
"change": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
},
},
)
class SetPermissionsSerializer(serializers.DictField):
pass
class OwnedObjectSerializer(
@@ -460,6 +470,7 @@ class OwnedObjectSerializer(
set_permissions = SetPermissionsSerializer(
label="Set permissions",
allow_empty=True,
required=False,
write_only=True,
)
@@ -2034,13 +2045,8 @@ class BulkEditSerializer(
def _validate_parameters_set_permissions(self, parameters) -> None:
if "set_permissions" not in parameters:
raise serializers.ValidationError("set_permissions not specified")
set_permissions = parameters["set_permissions"]
if set_permissions is not None:
set_permissions = SetPermissionsSerializer().run_validation(
set_permissions,
)
parameters["set_permissions"] = self.validate_set_permissions(
set_permissions,
parameters["set_permissions"],
)
if "owner" in parameters and parameters["owner"] is not None:
self._validate_owner(parameters["owner"])
@@ -2995,8 +3001,9 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
allow_null=True,
)
permissions = SetPermissionsSerializer(
permissions = serializers.DictField(
label="Set permissions",
allow_empty=False,
required=False,
write_only=True,
)
@@ -3032,8 +3039,8 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
)
return objects
def _validate_permissions(self, permissions) -> dict:
return self.validate_set_permissions(
def _validate_permissions(self, permissions) -> None:
self.validate_set_permissions(
permissions,
)
@@ -3057,11 +3064,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
if operation == "set_permissions":
permissions = attrs.get("permissions")
if permissions is not None:
if not permissions:
raise serializers.ValidationError(
"permissions must not be empty",
)
attrs["permissions"] = self._validate_permissions(permissions)
self._validate_permissions(permissions)
return attrs
@@ -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:
+1 -93
View File
@@ -1165,98 +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_non_dict_value(self, m) -> None:
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": False},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a dictionary", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_non_list_users(self, m) -> None:
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": False}},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a list", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_unknown_action(self, m) -> None:
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": {"not_a_real_action": {"users": [1]}},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Unknown permission action", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_null_is_a_noop(self, m) -> None:
"""
A `set_permissions: null` value is a deliberate no-op (e.g. a
request that only updates `owner`), not a validation error --
this must keep working even though every other non-dict value is
now rejected.
"""
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": None,
"owner": self.user.id,
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m) -> None:
self.setup_mock(m, "set_permissions")
@@ -1392,7 +1300,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.client.force_authenticate(user=user1)
permissions = {
"view": {"users": [user1.id]},
"owner": user1.id,
}
response = self.client.post(
-103
View File
@@ -1499,109 +1499,6 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(Tag.objects.filter(pk=self.t1.id).exists())
def test_bulk_object_set_permissions_rejects_empty_permissions(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an empty permissions dict
THEN:
- Validation fails rather than silently applying a no-op
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_rejects_non_dict_permissions(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and a non-dict permissions value
THEN:
- Validation fails rather than crashing
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": False,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_rejects_unknown_action(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an unrecognized permission action name
THEN:
- Validation fails rather than silently no-oping
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {"not_a_real_action": {"users": [self.user1.id]}},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_null_users_is_a_noop(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an explicit null for users/groups on an action
THEN:
- Request succeeds and is treated as "no users/groups for this
action", not a crash -- the normalized (id-checked) dict
returned by validate_set_permissions must actually be used,
not discarded in favor of the raw un-normalized input.
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {"view": {"users": None}},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_bulk_edit_object_permissions_validation(self) -> None:
"""
GIVEN:
+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"] == "":