diff --git a/src/paperless/fields.py b/src/paperless/fields.py new file mode 100644 index 0000000000..38009ec501 --- /dev/null +++ b/src/paperless/fields.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +OBFUSCATED_PASSWORD_MASK = "*" * 10 + + +def is_obfuscated_password(value: str) -> bool: + """ + True if the value is only mask characters (or empty), meaning a client echoed + the placeholder back rather than supplying a new secret + """ + return value.replace("*", "") == "" + + +class ObfuscatedPasswordField(serializers.CharField): + """ + Sends a constant mask string instead of the password in the clear + """ + + def to_representation(self, value) -> str: + return OBFUSCATED_PASSWORD_MASK + + def to_internal_value(self, data): + return data diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index fa1a48c6b0..81ccdf7f73 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -20,18 +20,19 @@ from rest_framework import serializers from rest_framework.authtoken.serializers import AuthTokenSerializer from paperless.config import OCR_RESERVED_USER_ARGS +from paperless.fields import ObfuscatedPasswordField +from paperless.fields import is_obfuscated_password from paperless.models import ApplicationConfiguration from paperless.network import validate_outbound_http_url from paperless.validators import reject_dangerous_svg from paperless.validators import validate_raster_image -from paperless_mail.serialisers import ObfuscatedPasswordField logger = logging.getLogger("paperless.settings") class PasswordValidationMixin: def _has_real_password(self, value: str | None) -> bool: - return bool(value) and value.replace("*", "") != "" + return bool(value) and not is_obfuscated_password(value) def validate_password(self, value: str) -> str: if not self._has_real_password(value): @@ -285,7 +286,7 @@ class ApplicationConfigurationSerializer( if data[field] == "": data[field] = None # Not a real value, don't overwrite the stored one - elif len(data[field].replace("*", "")) == 0: + elif is_obfuscated_password(data[field]): del data[field] return super().run_validation(data) diff --git a/src/paperless/tests/test_obfuscated_password.py b/src/paperless/tests/test_obfuscated_password.py new file mode 100644 index 0000000000..0370d6bffa --- /dev/null +++ b/src/paperless/tests/test_obfuscated_password.py @@ -0,0 +1,39 @@ +import pytest + +from paperless.fields import OBFUSCATED_PASSWORD_MASK +from paperless.fields import ObfuscatedPasswordField +from paperless.fields import is_obfuscated_password + + +class TestObfuscatedPasswordField: + @pytest.mark.parametrize( + "secret", + [ + "a", + "short", + "exactly10!", + "a-much-longer-secret-that-should-not-leak-its-length", + ], + ) + def test_representation_is_constant_regardless_of_length(self, secret: str) -> None: + assert ( + ObfuscatedPasswordField().to_representation(secret) + == OBFUSCATED_PASSWORD_MASK + ) + + +class TestIsObfuscatedPassword: + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("", True), + ("*", True), + ("******", True), + (OBFUSCATED_PASSWORD_MASK, True), + ("real-secret", False), + ("***x***", False), + (" ", False), + ], + ) + def test_detects_placeholder(self, value: str, *, expected: bool) -> None: + assert is_obfuscated_password(value) is expected diff --git a/src/paperless/views.py b/src/paperless/views.py index cfb1d7ebbb..94f90d448c 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -41,6 +41,7 @@ from documents.models import PaperlessTask from documents.permissions import PaperlessModelPermissions from documents.permissions import PaperlessObjectPermissions from documents.tasks import llmindex_index +from paperless.fields import is_obfuscated_password from paperless.filters import GroupFilterSet from paperless.filters import UserFilterSet from paperless.models import ApplicationConfiguration @@ -260,7 +261,7 @@ class ProfileView(GenericAPIView[Any]): user = self.request.user if hasattr(self.request, "user") else None password = serializer.validated_data.pop("password", None) - if password and password.replace("*", ""): + if password and not is_obfuscated_password(password): user.set_password(password) user.save() diff --git a/src/paperless_mail/serialisers.py b/src/paperless_mail/serialisers.py index 541b02bbb8..7c2a7030f4 100644 --- a/src/paperless_mail/serialisers.py +++ b/src/paperless_mail/serialisers.py @@ -8,23 +8,13 @@ from documents.serialisers import CorrespondentField from documents.serialisers import DocumentTypeField from documents.serialisers import OwnedObjectSerializer from documents.serialisers import TagsField +from paperless.fields import ObfuscatedPasswordField +from paperless.fields import is_obfuscated_password from paperless_mail.models import MailAccount from paperless_mail.models import MailRule from paperless_mail.models import ProcessedMail -class ObfuscatedPasswordField(serializers.CharField): - """ - Sends *** string instead of password in the clear - """ - - def to_representation(self, value) -> str: - return "*" * max(10, len(value)) - - def to_internal_value(self, data): - return data - - class MailAccountSerializer(OwnedObjectSerializer): password = ObfuscatedPasswordField() imap_port = serializers.IntegerField(required=True, allow_null=False) @@ -50,9 +40,8 @@ class MailAccountSerializer(OwnedObjectSerializer): ] def update(self, instance, validated_data): - if ( - "password" in validated_data - and len(validated_data.get("password").replace("*", "")) == 0 + if "password" in validated_data and is_obfuscated_password( + validated_data["password"], ): validated_data.pop("password") super().update(instance, validated_data) diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index 1da81cf666..eaa011521e 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -30,6 +30,7 @@ from documents.permissions import PaperlessObjectPermissions from documents.permissions import has_perms_owner_aware from documents.permissions import permitted_object_ids from documents.views import PassUserMixin +from paperless.fields import is_obfuscated_password from paperless.views import StandardPagination from paperless_mail.filters import ProcessedMailFilterSet from paperless_mail.mail import MailError @@ -126,9 +127,8 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]): return HttpResponseForbidden("Insufficient permissions") # account exists, use the password from there instead of *** - if ( - len(serializer.validated_data.get("password").replace("*", "")) == 0 - and existing_account is not None + if existing_account is not None and is_obfuscated_password( + serializer.validated_data.get("password"), ): serializer.validated_data["password"] = existing_account.password serializer.validated_data["account_type"] = existing_account.account_type