From 65ca78e9e7577c60108e5d6f8e8435ce5e541f9e Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:13:58 -0800 Subject: [PATCH 1/5] Security: fix/GHSA-7qqc-wrcw-2fj9 --- src/paperless_mail/tests/test_mail.py | 29 +++++++++++++++++++++++++++ src/paperless_mail/views.py | 20 +++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/paperless_mail/tests/test_mail.py b/src/paperless_mail/tests/test_mail.py index a0eed1635..c239c9f33 100644 --- a/src/paperless_mail/tests/test_mail.py +++ b/src/paperless_mail/tests/test_mail.py @@ -1815,6 +1815,35 @@ class TestMailAccountTestView(APITestCase): expected_str = "Unable to refresh oauth token" self.assertIn(expected_str, error_str) + def test_mail_account_test_view_existing_forbidden_for_other_owner(self): + other_user = User.objects.create_user( + username="otheruser", + password="testpassword", + ) + existing_account = MailAccount.objects.create( + name="Owned account", + imap_server="imap.example.com", + imap_port=993, + imap_security=MailAccount.ImapSecurity.SSL, + username="admin", + password="secret", + owner=other_user, + ) + data = { + "id": existing_account.id, + "imap_server": "imap.example.com", + "imap_port": 993, + "imap_security": MailAccount.ImapSecurity.SSL, + "username": "admin", + "password": "****", + "is_token": False, + } + + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.content.decode(), "Insufficient permissions") + class TestMailAccountProcess(APITestCase): def setUp(self): diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index b54bcb5f7..b3b7f812e 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -86,13 +86,26 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): request.data["name"] = datetime.datetime.now().isoformat() serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + existing_account = None + account_id = request.data.get("id") # account exists, use the password from there instead of *** and refresh_token / expiration if ( len(serializer.validated_data.get("password").replace("*", "")) == 0 - and request.data["id"] is not None + and account_id is not None ): - existing_account = MailAccount.objects.get(pk=request.data["id"]) + try: + existing_account = MailAccount.objects.get(pk=account_id) + except (TypeError, ValueError, MailAccount.DoesNotExist): + return HttpResponseBadRequest("Invalid account") + + if not has_perms_owner_aware( + request.user, + "change_mailaccount", + existing_account, + ): + return HttpResponseForbidden("Insufficient permissions") + serializer.validated_data["password"] = existing_account.password serializer.validated_data["account_type"] = existing_account.account_type serializer.validated_data["refresh_token"] = existing_account.refresh_token @@ -106,7 +119,8 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): ) as M: try: if ( - account.is_token + existing_account is not None + and account.is_token and account.expiration is not None and account.expiration < timezone.now() ): From f85094dc2b0324dce13a9ee65541212a472a5cf0 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:37:32 -0800 Subject: [PATCH 2/5] Set owner on OAuth mail credentials --- src/paperless_mail/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index b3b7f812e..5fceedd64 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -262,6 +262,7 @@ class OauthCallbackView(GenericAPIView): imap_server=imap_server, refresh_token=refresh_token, expiration=timezone.now() + timedelta(seconds=expires_in), + owner=request.user, defaults=defaults, ) return HttpResponseRedirect( From 1bb4b9b473e102b2e6c6ca37452a5b4ed96c09f7 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:43:28 -0800 Subject: [PATCH 3/5] More permissions on mail account test endpoint --- src/paperless_mail/tests/test_api.py | 18 ++++++++++++++++++ src/paperless_mail/tests/test_mail.py | 24 ++++++++++++++++++++++++ src/paperless_mail/views.py | 18 +++++++++++++----- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/paperless_mail/tests/test_api.py b/src/paperless_mail/tests/test_api.py index dd63c67ab..cbfe0f9a4 100644 --- a/src/paperless_mail/tests/test_api.py +++ b/src/paperless_mail/tests/test_api.py @@ -272,6 +272,24 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["success"], True) + def test_mail_account_test_existing_nonexistent_id_forbidden(self): + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "id": 999999, + "imap_server": "server.example.com", + "imap_port": 443, + "imap_security": MailAccount.ImapSecurity.SSL, + "username": "admin", + "password": "******", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.content.decode(), "Insufficient permissions") + def test_get_mail_accounts_owner_aware(self): """ GIVEN: diff --git a/src/paperless_mail/tests/test_mail.py b/src/paperless_mail/tests/test_mail.py index c239c9f33..f8ab14bdd 100644 --- a/src/paperless_mail/tests/test_mail.py +++ b/src/paperless_mail/tests/test_mail.py @@ -9,6 +9,7 @@ from datetime import timedelta from unittest import mock import pytest +from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.core.management import call_command from django.db import DatabaseError @@ -1699,6 +1700,10 @@ class TestMailAccountTestView(APITestCase): username="testuser", password="testpassword", ) + self.user.user_permissions.add( + *Permission.objects.filter(codename__in=["add_mailaccount"]), + ) + self.user.save() self.client.force_authenticate(user=self.user) self.url = "/api/mail_accounts/test/" @@ -1844,6 +1849,25 @@ class TestMailAccountTestView(APITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.content.decode(), "Insufficient permissions") + def test_mail_account_test_view_requires_add_permission_without_account_id(self): + self.user.user_permissions.remove( + *Permission.objects.filter(codename__in=["add_mailaccount"]), + ) + self.user.save() + data = { + "imap_server": "imap.example.com", + "imap_port": 993, + "imap_security": MailAccount.ImapSecurity.SSL, + "username": "admin", + "password": "secret", + "is_token": False, + } + + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.content.decode(), "Insufficient permissions") + class TestMailAccountProcess(APITestCase): def setUp(self): diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index 5fceedd64..b8ac2c485 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -89,15 +89,18 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): existing_account = None account_id = request.data.get("id") - # account exists, use the password from there instead of *** and refresh_token / expiration - if ( - len(serializer.validated_data.get("password").replace("*", "")) == 0 - and account_id is not None + # testing a new connection requires add permission + if account_id is None and not request.user.has_perms( + ["paperless_mail.add_mailaccount"], ): + return HttpResponseForbidden("Insufficient permissions") + + # testing an existing account requires change permission on that account + if account_id is not None: try: existing_account = MailAccount.objects.get(pk=account_id) except (TypeError, ValueError, MailAccount.DoesNotExist): - return HttpResponseBadRequest("Invalid account") + return HttpResponseForbidden("Insufficient permissions") if not has_perms_owner_aware( request.user, @@ -106,6 +109,11 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): ): 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 + ): serializer.validated_data["password"] = existing_account.password serializer.validated_data["account_type"] = existing_account.account_type serializer.validated_data["refresh_token"] = existing_account.refresh_token From 35be0850ec5b4ccd9156b7b9d87bbd09cda5912a Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:49:52 -0800 Subject: [PATCH 4/5] Bump version to 2.20.8 --- pyproject.toml | 2 +- src-ui/package.json | 2 +- src-ui/src/environments/environment.prod.ts | 2 +- src/paperless/version.py | 2 +- uv.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5fffad81c..ee3fc52a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.7" +version = "2.20.8" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" readme = "README.md" requires-python = ">=3.10" diff --git a/src-ui/package.json b/src-ui/package.json index aa1a0e71c..0f83a6583 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.7", + "version": "2.20.8", "scripts": { "preinstall": "npx only-allow pnpm", "ng": "ng", diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index 819bbed0c..0ad536f2b 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -6,7 +6,7 @@ export const environment = { apiVersion: '9', // match src/paperless/settings.py appTitle: 'Paperless-ngx', tag: 'prod', - version: '2.20.7', + version: '2.20.8', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src/paperless/version.py b/src/paperless/version.py index 7515698ad..a2f677230 100644 --- a/src/paperless/version.py +++ b/src/paperless/version.py @@ -1,6 +1,6 @@ from typing import Final -__version__: Final[tuple[int, int, int]] = (2, 20, 7) +__version__: Final[tuple[int, int, int]] = (2, 20, 8) # Version string like X.Y.Z __full_version_str__: Final[str] = ".".join(map(str, __version__)) # Version string like X.Y diff --git a/uv.lock b/uv.lock index f595c3dac..87bb02b00 100644 --- a/uv.lock +++ b/uv.lock @@ -1991,7 +1991,7 @@ wheels = [ [[package]] name = "paperless-ngx" -version = "2.20.7" +version = "2.20.8" source = { virtual = "." } dependencies = [ { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, From 98298e37cd7e7b6341c937ce8b4a8346fd204d40 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:43:19 -0800 Subject: [PATCH 5/5] Changelog v2.20.8 - GHA (#12135) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- docs/changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index fc66aead3..fb4229e5a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,7 @@ # Changelog +## paperless-ngx 2.20.8 + ## paperless-ngx 2.20.7 ### Bug Fixes