diff --git a/docs/changelog.md b/docs/changelog.md
index 189c74ce1..29f955256 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,9 +1,44 @@
# Changelog
+## paperless-ngx 2.20.4
+
+### Security
+
+- Resolve [GHSA-28cf-xvcf-hw6m](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-28cf-xvcf-hw6m)
+
+### Bug Fixes
+
+- Fix: propagate metadata override created value [@shamoon](https://github.com/shamoon) ([#11659](https://github.com/paperless-ngx/paperless-ngx/pull/11659))
+- Fix: support ordering by storage path name [@shamoon](https://github.com/shamoon) ([#11661](https://github.com/paperless-ngx/paperless-ngx/pull/11661))
+- Fix: validate cf integer values within PostgreSQL range [@shamoon](https://github.com/shamoon) ([#11666](https://github.com/paperless-ngx/paperless-ngx/pull/11666))
+- Fixhancement: add error handling and retry when opening index [@shamoon](https://github.com/shamoon) ([#11731](https://github.com/paperless-ngx/paperless-ngx/pull/11731))
+- Fix: fix recurring workflow to respect latest run time [@shamoon](https://github.com/shamoon) ([#11735](https://github.com/paperless-ngx/paperless-ngx/pull/11735))
+
+### All App Changes
+
+
+5 changes
+
+- Fix: propagate metadata override created value [@shamoon](https://github.com/shamoon) ([#11659](https://github.com/paperless-ngx/paperless-ngx/pull/11659))
+- Fix: support ordering by storage path name [@shamoon](https://github.com/shamoon) ([#11661](https://github.com/paperless-ngx/paperless-ngx/pull/11661))
+- Fix: validate cf integer values within PostgreSQL range [@shamoon](https://github.com/shamoon) ([#11666](https://github.com/paperless-ngx/paperless-ngx/pull/11666))
+- Fixhancement: add error handling and retry when opening index [@shamoon](https://github.com/shamoon) ([#11731](https://github.com/paperless-ngx/paperless-ngx/pull/11731))
+- Fix: fix recurring workflow to respect latest run time [@shamoon](https://github.com/shamoon) ([#11735](https://github.com/paperless-ngx/paperless-ngx/pull/11735))
+
+
## paperless-ngx 2.20.3
+### Security
+
+- Resolve [GHSA-7cq3-mhxq-w946](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-7cq3-mhxq-w946)
+
## paperless-ngx 2.20.2
+### Security
+
+- Resolve [GHSA-6653-vcx4-69mc](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-6653-vcx4-69mc)
+- Resolve [GHSA-24x5-wp64-9fcc](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-24x5-wp64-9fcc)
+
### Features / Enhancements
- Tweakhancement: dim inactive users in users-groups list [@shamoon](https://github.com/shamoon) ([#11537](https://github.com/paperless-ngx/paperless-ngx/pull/11537))
diff --git a/docs/configuration.md b/docs/configuration.md
index 2517d9cc1..6a3624d36 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -170,11 +170,18 @@ Available options are `postgresql` and `mariadb`.
!!! note
- A small pool is typically sufficient — for example, a size of 4.
- Make sure your PostgreSQL server's max_connections setting is large enough to handle:
- ```(Paperless workers + Celery workers) × pool size + safety margin```
- For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
- (4 + 2) × 4 + 10 = 34 connections required.
+ A pool of 8-10 connections per worker is typically sufficient.
+ If you encounter error messages such as `couldn't get a connection`
+ or database connection timeouts, you probably need to increase the pool size.
+
+ !!! warning
+ Make sure your PostgreSQL `max_connections` setting is large enough to handle the connection pools:
+ `(NB_PAPERLESS_WORKERS + NB_CELERY_WORKERS) × POOL_SIZE + SAFETY_MARGIN`. For example, with
+ 4 Paperless workers and 2 Celery workers, and a pool size of 8:``(4 + 2) × 8 + 10 = 58`,
+ so `max_connections = 60` (or even more) is appropriate.
+
+ This assumes only Paperless-ngx connects to your PostgreSQL instance. If you have other applications,
+ you should increase `max_connections` accordingly.
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
diff --git a/pyproject.toml b/pyproject.toml
index 2ba8325b3..a030ad840 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
-version = "2.20.3"
+version = "2.20.4"
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 7b5954b53..d7c53fc46 100644
--- a/src-ui/package.json
+++ b/src-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
- "version": "2.20.3",
+ "version": "2.20.4",
"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 c8bb844e9..d27ab9966 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.3',
+ version: '2.20.4',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index c06ffb641..bff68780b 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -421,7 +421,15 @@ def update_filename_and_move_files(
return
instance = instance.document
- def validate_move(instance, old_path: Path, new_path: Path):
+ def validate_move(instance, old_path: Path, new_path: Path, root: Path):
+ if not new_path.is_relative_to(root):
+ msg = (
+ f"Document {instance!s}: Refusing to move file outside root {root}: "
+ f"{new_path}."
+ )
+ logger.warning(msg)
+ raise CannotMoveFilesException(msg)
+
if not old_path.is_file():
# Can't do anything if the old file does not exist anymore.
msg = f"Document {instance!s}: File {old_path} doesn't exist."
@@ -510,12 +518,22 @@ def update_filename_and_move_files(
return
if move_original:
- validate_move(instance, old_source_path, instance.source_path)
+ validate_move(
+ instance,
+ old_source_path,
+ instance.source_path,
+ settings.ORIGINALS_DIR,
+ )
create_source_path_directory(instance.source_path)
shutil.move(old_source_path, instance.source_path)
if move_archive:
- validate_move(instance, old_archive_path, instance.archive_path)
+ validate_move(
+ instance,
+ old_archive_path,
+ instance.archive_path,
+ settings.ARCHIVE_DIR,
+ )
create_source_path_directory(instance.archive_path)
shutil.move(old_archive_path, instance.archive_path)
diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py
index 7d76e7f31..805cefbdb 100644
--- a/src/documents/templating/filepath.py
+++ b/src/documents/templating/filepath.py
@@ -262,6 +262,17 @@ def get_custom_fields_context(
return field_data
+def _is_safe_relative_path(value: str) -> bool:
+ if value == "":
+ return True
+
+ path = PurePath(value)
+ if path.is_absolute() or path.drive:
+ return False
+
+ return ".." not in path.parts
+
+
def validate_filepath_template_and_render(
template_string: str,
document: Document | None = None,
@@ -309,6 +320,12 @@ def validate_filepath_template_and_render(
)
rendered_template = template.render(context)
+ if not _is_safe_relative_path(rendered_template):
+ logger.warning(
+ "Template rendered an unsafe path (absolute or containing traversal).",
+ )
+ return None
+
# We're good!
return rendered_template
except UndefinedError:
diff --git a/src/documents/tests/test_api_objects.py b/src/documents/tests/test_api_objects.py
index 014dd3c2a..0eb99f023 100644
--- a/src/documents/tests/test_api_objects.py
+++ b/src/documents/tests/test_api_objects.py
@@ -219,6 +219,30 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(StoragePath.objects.count(), 1)
+ def test_api_create_storage_path_rejects_traversal(self):
+ """
+ GIVEN:
+ - API request to create a storage paths
+ - Storage path attempts directory traversal
+ WHEN:
+ - API is called
+ THEN:
+ - Correct HTTP 400 response
+ - No storage path is created
+ """
+ response = self.client.post(
+ self.ENDPOINT,
+ json.dumps(
+ {
+ "name": "Traversal path",
+ "path": "../../../../../tmp/proof",
+ },
+ ),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(StoragePath.objects.count(), 1)
+
def test_api_storage_path_placeholders(self):
"""
GIVEN:
diff --git a/src/paperless/version.py b/src/paperless/version.py
index c0c6439d4..0ce227357 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, 3)
+__version__: Final[tuple[int, int, int]] = (2, 20, 4)
# 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 1a6b6b1d7..596bebc75 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2961,7 +2961,7 @@ wheels = [
[[package]]
name = "paperless-ngx"
-version = "2.20.3"
+version = "2.20.4"
source = { virtual = "." }
dependencies = [
{ name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },