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
This commit is contained in:
stumpylog committed 2026-11-13 15:17:38 -08:00
1 parent b42bf50b7e
commit 13ffed0702
6 files changed
+75 -22

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 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)
@@ -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 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()
+4 -15
View File
@@ -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)
+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 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