Compare commits

..
Author SHA1 Message Date
stumpylog 13ffed0702 Fix: use a constant password mask and consolidate the no-change check
Return a constant 10 character mask instead.

Move ObfuscatedPasswordField into paperless/fields.py so core no longer
imports it from paperless_mail, and add is_obfuscated_password() to
replace the five checks
2026-11-13 15:17:38 -08:00
6 changed files with 75 additions and 22 deletions

No files matched your search

+23
View File
@@ -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
+4 -3
View File
@@ -20,18 +20,19 @@ from rest_framework import serializers
from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.authtoken.serializers import AuthTokenSerializer
from paperless.config import OCR_RESERVED_USER_ARGS 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.models import ApplicationConfiguration
from paperless.network import validate_outbound_http_url from paperless.network import validate_outbound_http_url
from paperless.validators import reject_dangerous_svg from paperless.validators import reject_dangerous_svg
from paperless.validators import validate_raster_image from paperless.validators import validate_raster_image
from paperless_mail.serialisers import ObfuscatedPasswordField
logger = logging.getLogger("paperless.settings") logger = logging.getLogger("paperless.settings")
class PasswordValidationMixin: class PasswordValidationMixin:
def _has_real_password(self, value: str | None) -> bool: 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: def validate_password(self, value: str) -> str:
if not self._has_real_password(value): if not self._has_real_password(value):
@@ -285,7 +286,7 @@ class ApplicationConfigurationSerializer(
if data[field] == "": if data[field] == "":
data[field] = None data[field] = None
# Not a real value, don't overwrite the stored one # 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] del data[field]
return super().run_validation(data) return super().run_validation(data)
@@ -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
+2 -1
View File
@@ -41,6 +41,7 @@ from documents.models import PaperlessTask
from documents.permissions import PaperlessModelPermissions from documents.permissions import PaperlessModelPermissions
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
from documents.tasks import llmindex_index from documents.tasks import llmindex_index
from paperless.fields import is_obfuscated_password
from paperless.filters import GroupFilterSet from paperless.filters import GroupFilterSet
from paperless.filters import UserFilterSet from paperless.filters import UserFilterSet
from paperless.models import ApplicationConfiguration from paperless.models import ApplicationConfiguration
@@ -260,7 +261,7 @@ class ProfileView(GenericAPIView[Any]):
user = self.request.user if hasattr(self.request, "user") else None user = self.request.user if hasattr(self.request, "user") else None
password = serializer.validated_data.pop("password", 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.set_password(password)
user.save() user.save()
+4 -15
View File
@@ -8,23 +8,13 @@ from documents.serialisers import CorrespondentField
from documents.serialisers import DocumentTypeField from documents.serialisers import DocumentTypeField
from documents.serialisers import OwnedObjectSerializer from documents.serialisers import OwnedObjectSerializer
from documents.serialisers import TagsField 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 MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail 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): class MailAccountSerializer(OwnedObjectSerializer):
password = ObfuscatedPasswordField() password = ObfuscatedPasswordField()
imap_port = serializers.IntegerField(required=True, allow_null=False) imap_port = serializers.IntegerField(required=True, allow_null=False)
@@ -50,9 +40,8 @@ class MailAccountSerializer(OwnedObjectSerializer):
] ]
def update(self, instance, validated_data): def update(self, instance, validated_data):
if ( if "password" in validated_data and is_obfuscated_password(
"password" in validated_data validated_data["password"],
and len(validated_data.get("password").replace("*", "")) == 0
): ):
validated_data.pop("password") validated_data.pop("password")
super().update(instance, validated_data) super().update(instance, validated_data)
+3 -3
View File
@@ -30,6 +30,7 @@ from documents.permissions import PaperlessObjectPermissions
from documents.permissions import has_perms_owner_aware from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.views import PassUserMixin from documents.views import PassUserMixin
from paperless.fields import is_obfuscated_password
from paperless.views import StandardPagination from paperless.views import StandardPagination
from paperless_mail.filters import ProcessedMailFilterSet from paperless_mail.filters import ProcessedMailFilterSet
from paperless_mail.mail import MailError from paperless_mail.mail import MailError
@@ -126,9 +127,8 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
# account exists, use the password from there instead of *** # account exists, use the password from there instead of ***
if ( if existing_account is not None and is_obfuscated_password(
len(serializer.validated_data.get("password").replace("*", "")) == 0 serializer.validated_data.get("password"),
and existing_account is not None
): ):
serializer.validated_data["password"] = existing_account.password serializer.validated_data["password"] = existing_account.password
serializer.validated_data["account_type"] = existing_account.account_type serializer.validated_data["account_type"] = existing_account.account_type