From 7942edfdf4fd4cf5e0f3df96725cdf795e389a31 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:07:12 -0700 Subject: [PATCH 01/14] Fixhancement: only offer basic auth for appropriate requests (#12362) --- .../auth-expiry.interceptor.spec.ts | 154 ++++++++++++++++++ .../interceptors/auth-expiry.interceptor.ts | 38 +++++ src-ui/src/main.ts | 6 + src/documents/tests/test_api_status.py | 7 + src/paperless/auth.py | 8 + 5 files changed, 213 insertions(+) create mode 100644 src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts create mode 100644 src-ui/src/app/interceptors/auth-expiry.interceptor.ts diff --git a/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts b/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts new file mode 100644 index 000000000..9bc22ca15 --- /dev/null +++ b/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts @@ -0,0 +1,154 @@ +import { HttpErrorResponse, HttpRequest } from '@angular/common/http' +import { TestBed } from '@angular/core/testing' +import { throwError } from 'rxjs' +import * as navUtils from '../utils/navigation' +import { AuthExpiryInterceptor } from './auth-expiry.interceptor' + +describe('AuthExpiryInterceptor', () => { + let interceptor: AuthExpiryInterceptor + let dateNowSpy: jest.SpiedFunction + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [AuthExpiryInterceptor], + }) + + interceptor = TestBed.inject(AuthExpiryInterceptor) + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('reloads when an API request returns 401', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + interceptor + .intercept(new HttpRequest('GET', '/api/documents/'), { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 401, + url: '/api/documents/', + }) + ), + }) + .subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('does not reload for non-401 errors', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + interceptor + .intercept(new HttpRequest('GET', '/api/documents/'), { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 500, + url: '/api/documents/', + }) + ), + }) + .subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).not.toHaveBeenCalled() + }) + + it('does not reload for non-api 401 responses', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + interceptor + .intercept(new HttpRequest('GET', '/accounts/profile/'), { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 401, + url: '/accounts/profile/', + }) + ), + }) + .subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).not.toHaveBeenCalled() + }) + + it('reloads only once even with multiple API 401 responses', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + const request = new HttpRequest('GET', '/api/documents/') + const handler = { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 401, + url: '/api/documents/', + }) + ), + } + + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('retries reload after cooldown for repeated API 401 responses', () => { + const reloadSpy = jest + .spyOn(navUtils, 'locationReload') + .mockImplementation(() => {}) + + dateNowSpy + .mockReturnValueOnce(1000) + .mockReturnValueOnce(2500) + .mockReturnValueOnce(3501) + + const request = new HttpRequest('GET', '/api/documents/') + const handler = { + handle: (_request) => + throwError( + () => + new HttpErrorResponse({ + status: 401, + url: '/api/documents/', + }) + ), + } + + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + interceptor.intercept(request, handler).subscribe({ + error: () => undefined, + }) + + expect(reloadSpy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src-ui/src/app/interceptors/auth-expiry.interceptor.ts b/src-ui/src/app/interceptors/auth-expiry.interceptor.ts new file mode 100644 index 000000000..3be9cc6e0 --- /dev/null +++ b/src-ui/src/app/interceptors/auth-expiry.interceptor.ts @@ -0,0 +1,38 @@ +import { + HttpErrorResponse, + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, +} from '@angular/common/http' +import { Injectable } from '@angular/core' +import { catchError, Observable, throwError } from 'rxjs' +import { locationReload } from '../utils/navigation' + +@Injectable() +export class AuthExpiryInterceptor implements HttpInterceptor { + private lastReloadAttempt = Number.NEGATIVE_INFINITY + + intercept( + request: HttpRequest, + next: HttpHandler + ): Observable> { + return next.handle(request).pipe( + catchError((error: unknown) => { + if ( + error instanceof HttpErrorResponse && + error.status === 401 && + request.url.includes('/api/') + ) { + const now = Date.now() + if (now - this.lastReloadAttempt >= 2000) { + this.lastReloadAttempt = now + locationReload() + } + } + + return throwError(() => error) + }) + ) + } +} diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index 7e57edcea..12fc46428 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -147,6 +147,7 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard' import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard' import { PermissionsGuard } from './app/guards/permissions.guard' import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor' +import { AuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor' import { CsrfInterceptor } from './app/interceptors/csrf.interceptor' import { DocumentTitlePipe } from './app/pipes/document-title.pipe' import { FilterPipe } from './app/pipes/filter.pipe' @@ -390,6 +391,11 @@ bootstrapApplication(AppComponent, { useClass: ApiVersionInterceptor, multi: true, }, + { + provide: HTTP_INTERCEPTORS, + useClass: AuthExpiryInterceptor, + multi: true, + }, FilterPipe, DocumentTitlePipe, { provide: NgbDateAdapter, useClass: ISODateAdapter }, diff --git a/src/documents/tests/test_api_status.py b/src/documents/tests/test_api_status.py index 9b7bf37ad..ec3f9e611 100644 --- a/src/documents/tests/test_api_status.py +++ b/src/documents/tests/test_api_status.py @@ -57,11 +57,18 @@ class TestSystemStatus(APITestCase): """ response = self.client.get(self.ENDPOINT) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response["WWW-Authenticate"], "Token") normal_user = User.objects.create_user(username="normal_user") self.client.force_login(normal_user) response = self.client.get(self.ENDPOINT) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_system_status_with_bad_basic_auth_challenges(self) -> None: + self.client.credentials(HTTP_AUTHORIZATION="Basic invalid") + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response["WWW-Authenticate"], 'Basic realm="api"') + def test_system_status_container_detection(self): """ GIVEN: diff --git a/src/paperless/auth.py b/src/paperless/auth.py index c68d63cf0..6857e1087 100644 --- a/src/paperless/auth.py +++ b/src/paperless/auth.py @@ -83,3 +83,11 @@ class PaperlessBasicAuthentication(authentication.BasicAuthentication): raise exceptions.AuthenticationFailed("MFA required") return user_tuple + + def authenticate_header(self, request): + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + if auth_header.lower().startswith("basic "): + return super().authenticate_header(request) + + # Still 401 for anonymous API access + return authentication.TokenAuthentication.keyword From a86c9d32feaf05a0ccca05bc067fb9accbb77538 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:26:41 -0700 Subject: [PATCH 02/14] Fix: fix file button hover color in dark mode (#12367) --- src-ui/src/theme.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss index e6a4ea113..61e5fe4c3 100644 --- a/src-ui/src/theme.scss +++ b/src-ui/src/theme.scss @@ -153,6 +153,11 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml, None: + old_storage_path = StoragePath.objects.create( + name="old-path", + path="old/{{title}}", + ) + new_storage_path = StoragePath.objects.create( + name="new-path", + path="new/{{title}}", + ) + original_bytes = b"original" + archive_bytes = b"archive" + + doc = Document.objects.create( + title="document", + mime_type="application/pdf", + checksum=hashlib.md5(original_bytes).hexdigest(), + archive_checksum=hashlib.md5(archive_bytes).hexdigest(), + filename="old/document.pdf", + archive_filename="old/document.pdf", + storage_path=old_storage_path, + ) + create_source_path_directory(doc.source_path) + doc.source_path.write_bytes(original_bytes) + create_source_path_directory(doc.archive_path) + doc.archive_path.write_bytes(archive_bytes) + + stale_doc = Document.objects.get(pk=doc.pk) + fresh_doc = Document.objects.get(pk=doc.pk) + fresh_doc.storage_path = new_storage_path + fresh_doc.save() + doc.refresh_from_db() + self.assertEqual(doc.filename, "new/document.pdf") + self.assertEqual(doc.archive_filename, "new/document.pdf") + + stale_doc.storage_path = new_storage_path + stale_doc.save() + + doc.refresh_from_db() + self.assertEqual(doc.filename, "new/document.pdf") + self.assertEqual(doc.archive_filename, "new/document.pdf") + self.assertIsFile(doc.source_path) + self.assertIsFile(doc.archive_path) + self.assertIsNotFile(settings.ORIGINALS_DIR / "old" / "document.pdf") + self.assertIsNotFile(settings.ARCHIVE_DIR / "old" / "document.pdf") + @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") def test_document_delete(self): document = Document() From 95dea787f20ad616a14b54990190cf8de07597c4 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:38:45 -0700 Subject: [PATCH 05/14] Fix: don't try to usermod/groupmod when non-root + update docs (#12365) (#12391) --- .../s6-overlay/s6-rc.d/init-modify-user/run | 11 +++++++++ docs/setup.md | 23 +++++++------------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/init-modify-user/run b/docker/rootfs/etc/s6-overlay/s6-rc.d/init-modify-user/run index aa617355d..f8430aee2 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/init-modify-user/run +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/init-modify-user/run @@ -2,6 +2,17 @@ # shellcheck shell=bash declare -r log_prefix="[init-user]" +# When the container is started as a non-root user (e.g. via `user: 999:999` +# in Docker Compose), usermod/groupmod require root and are meaningless. +# USERMAP_* variables only apply to the root-started path. +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + if [[ -n "${USERMAP_UID}" || -n "${USERMAP_GID}" ]]; then + echo "${log_prefix} WARNING: USERMAP_UID/USERMAP_GID are set but have no effect when the container is started as a non-root user" + fi + echo "${log_prefix} Running as non-root user ($(id --user):$(id --group)), skipping UID/GID remapping" + exit 0 +fi + declare -r usermap_original_uid=$(id -u paperless) declare -r usermap_original_gid=$(id -g paperless) declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid} diff --git a/docs/setup.md b/docs/setup.md index 3628eee68..28cd75c7f 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -140,24 +140,17 @@ a [superuser](usage.md#superusers) account. !!! warning - It is currently not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`. + It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`. -If you want to run Paperless as a rootless container, make this -change in `docker-compose.yml`: +If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping: -- Set the `user` running the container to map to the `paperless` - user in the container. This value (`user_id` below) should be - the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in - `docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID` - [here](configuration.md#docker). +```yaml +webserver: + image: ghcr.io/paperless-ngx/paperless-ngx:latest + user: '1000:1000' +``` -Your entry for Paperless should contain something like: - -> ``` -> webserver: -> image: ghcr.io/paperless-ngx/paperless-ngx:latest -> user: -> ``` +Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for the non-rootless case described in step 3. **File systems without inotify support (e.g. NFS)** From 0f7c02de5e8391d49e08031749ae95785956b670 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:31:09 -0700 Subject: [PATCH 06/14] Fix: test: add regression test for workflow save clobbering filename (#12390) Add test_workflow_document_updated_does_not_overwrite_filename to verify that run_workflows (DOCUMENT_UPDATED path) does not revert a DB filename that was updated by a concurrent bulk_update_documents task's update_filename_and_move_files call. The test replicates the race window by: - Updating the DB filename directly (simulating BUD-1 completing) - Mocking refresh_from_db so the stale in-memory filename persists - Asserting the DB filename is not clobbered after run_workflows Relates to: https://github.com/paperless-ngx/paperless-ngx/issues/12386 Co-authored-by: Claude Sonnet 4.6 --- src/documents/signals/handlers.py | 23 +++++++++-- src/documents/tests/test_workflows.py | 58 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 160e6021b..f8d670e30 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -867,10 +867,25 @@ def run_workflows( if not use_overrides: # limit title to 128 characters document.title = document.title[:128] - # Make sure the filename and archive filename are accurate - document.refresh_from_db(fields=["filename", "archive_filename"]) - # save first before setting tags - document.save() + # Save only the fields that workflow actions can set directly. + # Deliberately excludes filename and archive_filename — those are + # managed exclusively by update_filename_and_move_files via the + # post_save signal. Writing stale in-memory values here would revert + # a concurrent update_filename_and_move_files DB write, leaving the + # DB pointing at the old path while the file is already at the new + # one (see: https://github.com/paperless-ngx/paperless-ngx/issues/12386). + # modified has auto_now=True but is not auto-added when update_fields + # is specified, so it must be listed explicitly. + document.save( + update_fields=[ + "title", + "correspondent", + "document_type", + "storage_path", + "owner", + "modified", + ], + ) document.tags.set(doc_tag_ids) WorkflowRun.objects.create( diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index ba1e72e1f..924533698 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -956,6 +956,64 @@ class TestWorkflows( self.assertEqual(Path(doc.filename), expected_filename) self.assertTrue(doc.source_path.is_file()) + def test_workflow_document_updated_does_not_overwrite_filename(self) -> None: + """ + GIVEN: + - A document whose filename has been updated in the DB by a concurrent + bulk_update_documents task (simulating update_filename_and_move_files + completing and writing the new filename to the DB) + - A stale in-memory document instance still holding the old filename + - An active DOCUMENT_UPDATED workflow + WHEN: + - run_workflows is called with the stale in-memory instance + (as would happen in the second concurrent bulk_update_documents task) + THEN: + - The DB filename is NOT overwritten with the stale in-memory value + (regression test for GH #12386 — the race window between + refresh_from_db and document.save in run_workflows) + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.ASSIGNMENT, + assign_title="Updated by workflow", + ) + workflow = Workflow.objects.create(name="Race condition test workflow", order=0) + workflow.triggers.add(trigger) + workflow.actions.add(action) + workflow.save() + + doc = Document.objects.create( + title="race condition test", + mime_type="application/pdf", + checksum="racecondition123", + original_filename="old.pdf", + filename="old/path/old.pdf", + ) + + # Simulate BUD-1 completing update_filename_and_move_files: + # the DB now holds the new filename while BUD-2's in-memory instance is stale. + new_filename = "new/path/new.pdf" + Document.global_objects.filter(pk=doc.pk).update(filename=new_filename) + + # The stale instance still has filename="old/path/old.pdf" in memory. + # Mock refresh_from_db so the stale value persists through run_workflows, + # replicating the race window between refresh and save. + # Mock update_filename_and_move_files to prevent file-not-found errors + # since we are only testing DB state here. + with ( + mock.patch( + "documents.signals.handlers.update_filename_and_move_files", + ), + mock.patch.object(Document, "refresh_from_db"), + ): + run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc) + + # The DB filename must not have been reverted to the stale old value. + doc.refresh_from_db() + self.assertEqual(doc.filename, new_filename) + def test_document_added_workflow(self): trigger = WorkflowTrigger.objects.create( type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, From 9e9fc6213c6374779fa8bc3025422fa5a129a14f Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:39:15 -0700 Subject: [PATCH 07/14] Resolve GHSA-96jx-fj7m-qh6x --- src/documents/tests/test_api_search.py | 77 ++++++++++++++++++++++++++ src/documents/views.py | 12 +++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/documents/tests/test_api_search.py b/src/documents/tests/test_api_search.py index 191381721..d671fe546 100644 --- a/src/documents/tests/test_api_search.py +++ b/src/documents/tests/test_api_search.py @@ -1357,6 +1357,83 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase): self.assertEqual(results["custom_fields"][0]["id"], custom_field1.id) self.assertEqual(results["workflows"][0]["id"], workflow1.id) + def test_global_search_filters_owned_mail_objects(self): + user1 = User.objects.create_user("mail-search-user") + user2 = User.objects.create_user("other-mail-search-user") + user1.user_permissions.add( + Permission.objects.get(codename="view_mailaccount"), + Permission.objects.get(codename="view_mailrule"), + ) + + own_account = MailAccount.objects.create( + name="bank owned account", + username="owner@example.com", + password="secret", + imap_server="imap.owner.example.com", + imap_port=993, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + owner=user1, + ) + other_account = MailAccount.objects.create( + name="bank other account", + username="other@example.com", + password="secret", + imap_server="imap.other.example.com", + imap_port=993, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + owner=user2, + ) + unowned_account = MailAccount.objects.create( + name="bank shared account", + username="shared@example.com", + password="secret", + imap_server="imap.shared.example.com", + imap_port=993, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + own_rule = MailRule.objects.create( + name="bank owned rule", + account=own_account, + action=MailRule.MailAction.MOVE, + owner=user1, + ) + other_rule = MailRule.objects.create( + name="bank other rule", + account=other_account, + action=MailRule.MailAction.MOVE, + owner=user2, + ) + unowned_rule = MailRule.objects.create( + name="bank shared rule", + account=unowned_account, + action=MailRule.MailAction.MOVE, + ) + + self.client.force_authenticate(user1) + + response = self.client.get("/api/search/?query=bank") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertCountEqual( + [account["id"] for account in response.data["mail_accounts"]], + [own_account.id, unowned_account.id], + ) + self.assertCountEqual( + [rule["id"] for rule in response.data["mail_rules"]], + [own_rule.id, unowned_rule.id], + ) + self.assertNotIn( + other_account.id, + [account["id"] for account in response.data["mail_accounts"]], + ) + self.assertNotIn( + other_rule.id, + [rule["id"] for rule in response.data["mail_rules"]], + ) + def test_global_search_bad_request(self): """ WHEN: diff --git a/src/documents/views.py b/src/documents/views.py index 6974ddc1c..e8a929859 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -2112,13 +2112,21 @@ class GlobalSearchView(PassUserMixin): ) groups = groups[:OBJECT_LIMIT] mail_rules = ( - MailRule.objects.filter(name__icontains=query) + get_objects_for_user_owner_aware( + request.user, + "view_mailrule", + MailRule, + ).filter(name__icontains=query) if request.user.has_perm("paperless_mail.view_mailrule") else [] ) mail_rules = mail_rules[:OBJECT_LIMIT] mail_accounts = ( - MailAccount.objects.filter(name__icontains=query) + get_objects_for_user_owner_aware( + request.user, + "view_mailaccount", + MailAccount, + ).filter(name__icontains=query) if request.user.has_perm("paperless_mail.view_mailaccount") else [] ) From 2cb155e71751b73e1eb856d8abfde998893e6d05 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:47:37 -0700 Subject: [PATCH 08/14] Bump version to 2.20.12 --- 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 ae95894c8..c22d3545e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.11" +version = "2.20.12" 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 33feeabb9..a578cbeef 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.11", + "version": "2.20.12", "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 bafd0dfab..46fb6fdfe 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.11', + version: '2.20.12', 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 9f9841618..16df09624 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, 11) +__version__: Final[tuple[int, int, int]] = (2, 20, 12) # 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 3e2b7d5cb..8c15a6252 100644 --- a/uv.lock +++ b/uv.lock @@ -1991,7 +1991,7 @@ wheels = [ [[package]] name = "paperless-ngx" -version = "2.20.11" +version = "2.20.12" source = { virtual = "." } dependencies = [ { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, From d2a752a19654d8e63ddeb0d640196b6ea815a2dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:23:06 -0700 Subject: [PATCH 09/14] Documentation: Add v2.20.12 changelog (#12407) * Changelog v2.20.12 - GHA * Update changelog for version 2.20.12 Added security advisory for GHSA-96jx-fj7m-qh6x and fixed workflow saves and usermod/groupmod issues. --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/changelog.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index ec2342060..e2b1b597c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,32 @@ # Changelog +## paperless-ngx 2.20.12 + +### Security + +- Resolve [GHSA-96jx-fj7m-qh6x](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-96jx-fj7m-qh6x) + +### Bug Fixes + +- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390)) +- Fix: don't try to usermod/groupmod when non-root + update docs (#12365) [@stumpylog](https://github.com/stumpylog) ([#12391](https://github.com/paperless-ngx/paperless-ngx/pull/12391)) +- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389)) +- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388)) +- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367)) +- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362)) + +### All App Changes + +
+5 changes + +- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390)) +- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389)) +- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388)) +- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367)) +- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362)) +
+ ## paperless-ngx 2.20.11 ### Security From 7dbf8bdd4aff6ceb4eedd8a4aba7652978d1dc74 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:44:28 -0700 Subject: [PATCH 10/14] Fix: enforce permissions when attaching accounts to mail rules --- src/paperless_mail/serialisers.py | 15 ++++ src/paperless_mail/tests/test_api.py | 108 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/src/paperless_mail/serialisers.py b/src/paperless_mail/serialisers.py index b38c8e78c..aff3e75da 100644 --- a/src/paperless_mail/serialisers.py +++ b/src/paperless_mail/serialisers.py @@ -1,5 +1,8 @@ +from django.utils.translation import gettext as _ from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied +from documents.permissions import has_perms_owner_aware from documents.serialisers import CorrespondentField from documents.serialisers import DocumentTypeField from documents.serialisers import OwnedObjectSerializer @@ -127,6 +130,18 @@ class MailRuleSerializer(OwnedObjectSerializer): return attrs + def validate_account(self, account): + if self.user is not None and has_perms_owner_aware( + self.user, + "change_mailaccount", + account, + ): + return account + + raise PermissionDenied( + _("Insufficient permissions."), + ) + def validate_maximum_age(self, value): if value > 36500: # ~100 years raise serializers.ValidationError("Maximum mail age is unreasonably large.") diff --git a/src/paperless_mail/tests/test_api.py b/src/paperless_mail/tests/test_api.py index cbfe0f9a4..905509ec1 100644 --- a/src/paperless_mail/tests/test_api.py +++ b/src/paperless_mail/tests/test_api.py @@ -632,6 +632,114 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): self.assertEqual(returned_rule1.name, "Updated Name 1") self.assertEqual(returned_rule1.action, MailRule.MailAction.DELETE) + def test_create_mail_rule_forbidden_for_unpermitted_account(self): + other_user = User.objects.create_user(username="mail-owner") + foreign_account = MailAccount.objects.create( + name="ForeignEmail", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + owner=other_user, + ) + + response = self.client.post( + self.ENDPOINT, + data={ + "name": "Rule1", + "account": foreign_account.pk, + "folder": "INBOX", + "filter_from": "from@example.com", + "maximum_age": 30, + "action": MailRule.MailAction.MARK_READ, + "assign_title_from": MailRule.TitleSource.FROM_SUBJECT, + "assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING, + "order": 0, + "attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(MailRule.objects.count(), 0) + + def test_create_mail_rule_allowed_for_granted_account_change_permission(self): + other_user = User.objects.create_user(username="mail-owner") + foreign_account = MailAccount.objects.create( + name="ForeignEmail", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + owner=other_user, + ) + assign_perm("change_mailaccount", self.user, foreign_account) + + response = self.client.post( + self.ENDPOINT, + data={ + "name": "Rule1", + "account": foreign_account.pk, + "folder": "INBOX", + "filter_from": "from@example.com", + "maximum_age": 30, + "action": MailRule.MailAction.MARK_READ, + "assign_title_from": MailRule.TitleSource.FROM_SUBJECT, + "assign_correspondent_from": MailRule.CorrespondentSource.FROM_NOTHING, + "order": 0, + "attachment_type": MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(MailRule.objects.get().account, foreign_account) + + def test_update_mail_rule_forbidden_for_unpermitted_account(self): + own_account = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + other_user = User.objects.create_user(username="mail-owner") + foreign_account = MailAccount.objects.create( + name="ForeignEmail", + username="username2", + password="password2", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + owner=other_user, + ) + rule1 = MailRule.objects.create( + name="Rule1", + account=own_account, + folder="INBOX", + filter_from="from@example.com", + maximum_age=30, + action=MailRule.MailAction.MARK_READ, + assign_title_from=MailRule.TitleSource.FROM_SUBJECT, + assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING, + order=0, + attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY, + ) + + response = self.client.patch( + f"{self.ENDPOINT}{rule1.pk}/", + data={"account": foreign_account.pk}, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + rule1.refresh_from_db() + self.assertEqual(rule1.account, own_account) + def test_get_mail_rules_owner_aware(self): """ GIVEN: From f84e0097e5b3a5383c662759ae0a780f5d63d416 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:55:36 -0700 Subject: [PATCH 11/14] Fix validate document link targets --- src/documents/serialisers.py | 43 +++++++-- src/documents/tests/test_api_bulk_edit.py | 44 +++++++++ src/documents/tests/test_api_custom_fields.py | 95 +++++++++++++++++++ 3 files changed, 176 insertions(+), 6 deletions(-) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 647e0e8b5..ea8cc70b6 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -853,6 +853,25 @@ class ReadWriteSerializerMethodField(serializers.SerializerMethodField): return {self.field_name: data} +def validate_documentlink_targets(user, doc_ids): + if Document.objects.filter(id__in=doc_ids).count() != len(doc_ids): + raise serializers.ValidationError( + "Some documents in value don't exist or were specified twice.", + ) + + if user is None: + return + + target_documents = Document.objects.filter(id__in=doc_ids).select_related("owner") + if not all( + has_perms_owner_aware(user, "change_document", document) + for document in target_documents + ): + raise PermissionDenied( + _("Insufficient permissions."), + ) + + class CustomFieldInstanceSerializer(serializers.ModelSerializer): field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all()) value = ReadWriteSerializerMethodField(allow_null=True) @@ -943,12 +962,11 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): "Value must be a list", ) doc_ids = data["value"] - if Document.objects.filter(id__in=doc_ids).count() != len( - data["value"], - ): - raise serializers.ValidationError( - "Some documents in value don't exist or were specified twice.", - ) + request = self.context.get("request") + validate_documentlink_targets( + getattr(request, "user", None) if request is not None else None, + doc_ids, + ) return data @@ -1498,6 +1516,19 @@ class BulkEditSerializer( f"Some custom fields in {name} don't exist or were specified twice.", ) + if isinstance(custom_fields, dict): + custom_field_map = CustomField.objects.in_bulk(ids) + for raw_field_id, value in custom_fields.items(): + field = custom_field_map.get(int(raw_field_id)) + if ( + field is not None + and field.data_type == CustomField.FieldDataType.DOCUMENTLINK + and value is not None + ): + if not isinstance(value, list): + raise serializers.ValidationError("Value must be a list") + validate_documentlink_targets(self.user, value) + def validate_method(self, method): if method == "set_correspondent": return bulk_edit.set_correspondent diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index be7a81cd4..8400372a7 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -262,6 +262,50 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id]) self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id]) + @mock.patch("documents.serialisers.bulk_edit.modify_custom_fields") + def test_api_modify_custom_fields_documentlink_forbidden_for_unpermitted_target( + self, + m, + ): + self.setup_mock(m, "modify_custom_fields") + user = User.objects.create_user(username="doc-owner") + user.user_permissions.add(Permission.objects.get(codename="change_document")) + other_user = User.objects.create_user(username="other-user") + source_doc = Document.objects.create( + checksum="source", + title="Source", + owner=user, + ) + target_doc = Document.objects.create( + checksum="target", + title="Target", + owner=other_user, + ) + doclink_field = CustomField.objects.create( + name="doclink", + data_type=CustomField.FieldDataType.DOCUMENTLINK, + ) + + self.client.force_authenticate(user=user) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [source_doc.id], + "method": "modify_custom_fields", + "parameters": { + "add_custom_fields": {doclink_field.id: [target_doc.id]}, + "remove_custom_fields": [], + }, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + m.assert_not_called() + @mock.patch("documents.serialisers.bulk_edit.modify_custom_fields") def test_api_modify_custom_fields_with_values(self, m): self.setup_mock(m, "modify_custom_fields") diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index 8cc8f2cb2..998bc445a 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -6,6 +6,7 @@ from unittest.mock import ANY from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.test import override_settings +from guardian.shortcuts import assign_perm from rest_framework import status from rest_framework.test import APITestCase @@ -1247,6 +1248,100 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(doc5.custom_fields.first().value, [1]) + def test_documentlink_patch_requires_change_permission_on_target_documents(self): + source_owner = User.objects.create_user(username="source-owner") + source_owner.user_permissions.add( + Permission.objects.get(codename="change_document"), + ) + other_user = User.objects.create_user(username="other-user") + + source_doc = Document.objects.create( + title="Source", + checksum="source", + mime_type="application/pdf", + owner=source_owner, + ) + target_doc = Document.objects.create( + title="Target", + checksum="target", + mime_type="application/pdf", + owner=other_user, + ) + custom_field_doclink = CustomField.objects.create( + name="Test Custom Field Doc Link", + data_type=CustomField.FieldDataType.DOCUMENTLINK, + ) + + self.client.force_authenticate(user=source_owner) + + resp = self.client.patch( + f"/api/documents/{source_doc.id}/", + data={ + "custom_fields": [ + { + "field": custom_field_doclink.id, + "value": [target_doc.id], + }, + ], + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + CustomFieldInstance.objects.filter(field=custom_field_doclink).count(), + 0, + ) + + def test_documentlink_patch_allowed_with_change_permission_on_target_documents( + self, + ): + source_owner = User.objects.create_user(username="source-owner") + source_owner.user_permissions.add( + Permission.objects.get(codename="change_document"), + ) + other_user = User.objects.create_user(username="other-user") + + source_doc = Document.objects.create( + title="Source", + checksum="source", + mime_type="application/pdf", + owner=source_owner, + ) + target_doc = Document.objects.create( + title="Target", + checksum="target", + mime_type="application/pdf", + owner=other_user, + ) + custom_field_doclink = CustomField.objects.create( + name="Test Custom Field Doc Link", + data_type=CustomField.FieldDataType.DOCUMENTLINK, + ) + + assign_perm("change_document", source_owner, target_doc) + self.client.force_authenticate(user=source_owner) + + resp = self.client.patch( + f"/api/documents/{source_doc.id}/", + data={ + "custom_fields": [ + { + "field": custom_field_doclink.id, + "value": [target_doc.id], + }, + ], + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + target_doc.refresh_from_db() + self.assertEqual( + target_doc.custom_fields.get(field=custom_field_doclink).value, + [source_doc.id], + ) + def test_custom_field_filters(self): custom_field_string = CustomField.objects.create( name="Test Custom Field String", From 3cbdf5d0b7a4aa59aa00cdba274f7734e43a02f7 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:20:59 -0700 Subject: [PATCH 12/14] Fix: require view permission for more-like search --- src/documents/tests/test_api_search.py | 52 ++++++++++++++++++++++++++ src/documents/views.py | 25 ++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/documents/tests/test_api_search.py b/src/documents/tests/test_api_search.py index d671fe546..79fae018a 100644 --- a/src/documents/tests/test_api_search.py +++ b/src/documents/tests/test_api_search.py @@ -772,6 +772,58 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase): self.assertEqual(results[0]["id"], d3.id) self.assertEqual(results[1]["id"], d1.id) + def test_search_more_like_requires_view_permission_on_seed_document(self): + """ + GIVEN: + - A user can search documents they own + - Another user's private document exists with similar content + WHEN: + - The user requests more-like-this for the private seed document + THEN: + - The request is rejected + """ + owner = User.objects.create_user("owner") + attacker = User.objects.create_user("attacker") + attacker.user_permissions.add( + Permission.objects.get(codename="view_document"), + ) + + private_seed = Document.objects.create( + title="private bank statement", + content="quarterly treasury bank statement wire transfer", + checksum="seed", + owner=owner, + pk=10, + ) + visible_doc = Document.objects.create( + title="attacker-visible match", + content="quarterly treasury bank statement wire transfer summary", + checksum="visible", + owner=attacker, + pk=11, + ) + other_doc = Document.objects.create( + title="unrelated", + content="completely different topic", + checksum="other", + owner=attacker, + pk=12, + ) + + with AsyncWriter(index.open_index()) as writer: + index.update_document(writer, private_seed) + index.update_document(writer, visible_doc) + index.update_document(writer, other_doc) + + self.client.force_authenticate(user=attacker) + + response = self.client.get( + f"/api/documents/?more_like_id={private_seed.id}", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.content, b"Insufficient permissions.") + def test_search_filtering(self): t = Tag.objects.create(name="tag") t2 = Tag.objects.create(name="tag2") diff --git a/src/documents/views.py b/src/documents/views.py index e8a929859..5985c17a8 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -49,6 +49,7 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.timezone import make_aware from django.utils.translation import get_language +from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.decorators.cache import cache_control from django.views.decorators.http import condition @@ -70,6 +71,7 @@ from rest_framework import parsers from rest_framework import serializers from rest_framework.decorators import action from rest_framework.exceptions import NotFound +from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import ValidationError from rest_framework.filters import OrderingFilter from rest_framework.filters import SearchFilter @@ -1369,11 +1371,28 @@ class UnifiedSearchViewSet(DocumentViewSet): filtered_queryset = super().filter_queryset(queryset) if self._is_search_request(): - from documents import index - if "query" in self.request.query_params: + from documents import index + query_class = index.DelayedFullTextQuery elif "more_like_id" in self.request.query_params: + try: + more_like_doc_id = int(self.request.query_params["more_like_id"]) + more_like_doc = Document.objects.select_related("owner").get( + pk=more_like_doc_id, + ) + except (TypeError, ValueError, Document.DoesNotExist): + raise PermissionDenied(_("Invalid more_like_id")) + + if not has_perms_owner_aware( + self.request.user, + "view_document", + more_like_doc, + ): + raise PermissionDenied(_("Insufficient permissions.")) + + from documents import index + query_class = index.DelayedMoreLikeThisQuery else: raise ValueError @@ -1409,6 +1428,8 @@ class UnifiedSearchViewSet(DocumentViewSet): return response except NotFound: raise + except PermissionDenied as e: + return HttpResponseForbidden(str(e.detail)) except Exception as e: logger.warning(f"An error occurred listing search results: {e!s}") return HttpResponseBadRequest( From cc71aad058bded68f5cbe31b4be379f1644c4f2c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:24:23 -0700 Subject: [PATCH 13/14] Fix: suggest corrections only if visible results --- src/documents/index.py | 9 ++++++- src/documents/tests/test_api_search.py | 34 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/documents/index.py b/src/documents/index.py index 8afc31fe9..f6e0821db 100644 --- a/src/documents/index.py +++ b/src/documents/index.py @@ -470,7 +470,14 @@ class DelayedFullTextQuery(DelayedQuery): try: corrected = self.searcher.correct_query(q, q_str) if corrected.string != q_str: - suggested_correction = corrected.string + corrected_results = self.searcher.search( + corrected.query, + limit=1, + filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader), + scored=False, + ) + if len(corrected_results) > 0: + suggested_correction = corrected.string except Exception as e: logger.info( "Error while correcting query %s: %s", diff --git a/src/documents/tests/test_api_search.py b/src/documents/tests/test_api_search.py index 79fae018a..190e0c431 100644 --- a/src/documents/tests/test_api_search.py +++ b/src/documents/tests/test_api_search.py @@ -702,6 +702,40 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase): self.assertEqual(correction, None) + def test_search_spelling_suggestion_suppressed_for_private_terms(self): + owner = User.objects.create_user("owner") + attacker = User.objects.create_user("attacker") + attacker.user_permissions.add( + Permission.objects.get(codename="view_document"), + ) + + with AsyncWriter(index.open_index()) as writer: + for i in range(55): + private_doc = Document.objects.create( + checksum=f"p{i}", + pk=100 + i, + title=f"Private Document {i + 1}", + content=f"treasury document {i + 1}", + owner=owner, + ) + visible_doc = Document.objects.create( + checksum=f"v{i}", + pk=200 + i, + title=f"Visible Document {i + 1}", + content=f"public ledger {i + 1}", + owner=attacker, + ) + index.update_document(writer, private_doc) + index.update_document(writer, visible_doc) + + self.client.force_authenticate(user=attacker) + + response = self.client.get("/api/documents/?query=treasurx") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertIsNone(response.data["corrected_query"]) + @mock.patch( "whoosh.searching.Searcher.correct_query", side_effect=Exception("Test error"), From 9646b8c67d119dcd4e2f67bb70ad0870febfd976 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:50:04 -0700 Subject: [PATCH 14/14] Bump version to 2.20.13 --- 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 c22d3545e..cc9e1a3cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.12" +version = "2.20.13" 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 a578cbeef..6c5ff8295 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.12", + "version": "2.20.13", "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 46fb6fdfe..ff10ca3f2 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.12', + version: '2.20.13', 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 16df09624..b0b675c87 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, 12) +__version__: Final[tuple[int, int, int]] = (2, 20, 13) # 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 8c15a6252..9702c6dfc 100644 --- a/uv.lock +++ b/uv.lock @@ -1991,7 +1991,7 @@ wheels = [ [[package]] name = "paperless-ngx" -version = "2.20.12" +version = "2.20.13" source = { virtual = "." } dependencies = [ { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },