diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 4ad0579f..9591f605 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -1,4 +1,8 @@ +## 2026-03-20 - Safe Path Traversal Prevention in Low-Level Utilities +**Vulnerability:** The generic file utility `hash_file` in `app/utils/file_operations.py` accepted any file path and was vulnerable to reading arbitrary files via path traversal (e.g., `../../../etc/passwd`) or absolute paths if an attacker could control the `filepath` argument. +**Learning:** Naively checking for `".." in path` breaks legitimate relative paths used internally by the application. Blocking absolute paths entirely also breaks functionality. Input validation should occur at the API boundary, but for defense-in-depth, low-level utilities must enforce expected boundaries (e.g., the application's `workdir`). +**Prevention:** Use `pathlib.Path.resolve()` on both the target path and the allowed base directory (`settings.workdir`). Ensure the resolved target path is strictly within the allowed boundary using `filepath_obj.relative_to(workdir_obj)`, catching the `ValueError` that is raised when the path is out of bounds. This safely blocks both relative traversal attacks and arbitrary absolute paths. ## 2025-05-18 - [SSRF Bypass via DNS Resolution Failure] **Vulnerability:** The `is_private_ip` function in `app/utils/network.py` failed open (returned `False`) when a hostname could not be resolved (`socket.gaierror`). **Learning:** This fail-open pattern was originally added to allow external domains in tests, but in production, it created a severe SSRF risk. An attacker could bypass SSRF protections by providing a URL that fails to resolve during the security check but resolves later (DNS rebinding), or by exploiting internal routing behaviors via unresolvable addresses. -**Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic. \ No newline at end of file +**Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic. diff --git a/CHANGELOG.md b/CHANGELOG.md index c441ae6e..d9999ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Documentation + +- **changelog**: Update changelog [skip ci] + ([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70)) + +- **changelog**: Update changelog [skip ci] + ([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08)) + +### Testing + +- Add assertions for task enqueuing parameters + ([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a)) + + +## Unreleased + +### Documentation + +- **changelog**: Update changelog [skip ci] + ([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08)) + +### Testing + +- Add assertions for task enqueuing parameters + ([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a)) + + +## Unreleased + + +## v0.172.2 (2026-03-23) + +### Bug Fixes + +- Adapt TemplateResponse calls to Starlette 1.0 new-style API + ([`c4e10be`](https://github.com/christianlouis/DocuElevate/commit/c4e10bee5e096e71a5bc4fac4928f69e5c04f2fb)) + +- Update test assertions and lint fixes for Starlette 1.0 TemplateResponse API + ([`93629ff`](https://github.com/christianlouis/DocuElevate/commit/93629ff44083d43f79fdd49431457023e53d13e4)) + +- **build**: Remove --omit=dev from npm ci in Dockerfile frontend-builder stage + ([`b4e0067`](https://github.com/christianlouis/DocuElevate/commit/b4e0067a27e2fb161349bd38c6d3b3f3bcb86972)) + +### Documentation + +- **changelog**: Update changelog [skip ci] + ([`0841713`](https://github.com/christianlouis/DocuElevate/commit/084171395d1076c716aa500a516118db49468ff5)) + + +## Unreleased + ## v0.172.2 (2026-03-23) diff --git a/app/utils/file_operations.py b/app/utils/file_operations.py index 485da36d..03e6fd0f 100644 --- a/app/utils/file_operations.py +++ b/app/utils/file_operations.py @@ -7,8 +7,19 @@ def hash_file(filepath: str | Path, chunk_size: int = 65536) -> str: Returns the SHA-256 hash of the file at 'filepath'. Reads the file in chunks to handle large files efficiently. """ + from app.config import settings + + filepath_obj = Path(filepath).resolve() + workdir_obj = Path(settings.workdir).resolve() + + # Security check: Ensure the resolved path is strictly within the allowed workdir + try: + filepath_obj.relative_to(workdir_obj) + except ValueError: + raise FileNotFoundError(f"Access denied: path traversal attempt or file outside workdir '{filepath}'") + sha256 = hashlib.sha256() - with open(filepath, "rb") as f: + with open(filepath_obj, "rb") as f: while True: data = f.read(chunk_size) if not data: diff --git a/app/utils/network.py b/app/utils/network.py index 2bc87c9b..24d2fd67 100644 --- a/app/utils/network.py +++ b/app/utils/network.py @@ -1,6 +1,8 @@ import ipaddress import logging +import posixpath import socket +from urllib.parse import urlsplit, urlunsplit logger = logging.getLogger(__name__) @@ -36,12 +38,26 @@ def is_private_ip(hostname: str) -> bool: def join_url(base: str, *parts: str) -> str: """ - Safely join a base URL and multiple path parts. - Handles double slashes while preserving the protocol '://'. + Safely join a base URL with one or more path parts. + + Uses urllib.parse to correctly handle scheme/netloc/query/fragment so that + only the path component is normalised (double slashes removed via + posixpath.join). The scheme separator ``://`` is therefore never at risk + of being collapsed. + + Examples: + join_url("https://example.com/dav/", "/remote/", "file.pdf") + -> "https://example.com/dav/remote/file.pdf" """ - url = "/".join([base, *parts]) - url = url.replace("://", "$PLACEHOLDER$") - while "//" in url: - url = url.replace("//", "/") - url = url.replace("$PLACEHOLDER$", "://") - return url + parsed = urlsplit(base) + # Strip leading/trailing slashes from every part so posixpath.join + # produces a clean joined path without accidental double slashes. + stripped_parts = [p.strip("/") for p in parts if p.strip("/")] + base_path = parsed.path.rstrip("/") + if stripped_parts: + new_path = base_path + "/" + "/".join(stripped_parts) + else: + new_path = base_path + # Normalise any remaining double slashes in the path only. + new_path = posixpath.normpath(new_path) if new_path else "/" + return urlunsplit((parsed.scheme, parsed.netloc, new_path, parsed.query, parsed.fragment)) diff --git a/tests/test_upload_to_nextcloud_join_url.py b/tests/test_upload_to_nextcloud_join_url.py index de14ed60..705af7c5 100644 --- a/tests/test_upload_to_nextcloud_join_url.py +++ b/tests/test_upload_to_nextcloud_join_url.py @@ -1,4 +1,3 @@ -import os from unittest.mock import MagicMock, patch import pytest @@ -7,13 +6,13 @@ from app.tasks.upload_to_nextcloud import upload_to_nextcloud @pytest.fixture -def mock_settings(): +def mock_settings(tmp_path): with patch("app.tasks.upload_to_nextcloud.settings") as mock: mock.nextcloud_upload_url = "http://nextcloud.local/" mock.nextcloud_username = "testuser" mock.nextcloud_password = "testpassword" mock.nextcloud_folder = "uploads" - mock.workdir = "/tmp/workdir" + mock.workdir = str(tmp_path) mock.http_request_timeout = 30 yield mock @@ -31,11 +30,10 @@ def mock_requests(): yield mock -def test_upload_to_nextcloud_url_construction(mock_settings, mock_requests): - file_path = "/tmp/workdir/test_file.txt" +def test_upload_to_nextcloud_url_construction(tmp_path, mock_settings, mock_requests): + file_path = str(tmp_path / "test_file.txt") # Create dummy file - os.makedirs("/tmp/workdir", exist_ok=True) with open(file_path, "w") as f: f.write("test content")