Merge pull request #819 from christianlouis/copilot/sub-pr-808

Fix join_url sentinel hack, resolve merge conflicts, improve test isolation
This commit is contained in:
Christian Krakau-Louis
2026-03-23 17:25:38 +01:00
committed by GitHub
5 changed files with 93 additions and 16 deletions
+4
View File
@@ -1,3 +1,7 @@
## 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] ## 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`). **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. **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.
+51
View File
@@ -12,6 +12,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## 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) ## v0.172.2 (2026-03-23)
+12 -1
View File
@@ -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'. Returns the SHA-256 hash of the file at 'filepath'.
Reads the file in chunks to handle large files efficiently. 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() sha256 = hashlib.sha256()
with open(filepath, "rb") as f: with open(filepath_obj, "rb") as f:
while True: while True:
data = f.read(chunk_size) data = f.read(chunk_size)
if not data: if not data:
+21 -8
View File
@@ -1,6 +1,7 @@
import ipaddress import ipaddress
import logging import logging
import socket import socket
from urllib.parse import urlsplit, urlunsplit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,12 +37,24 @@ def is_private_ip(hostname: str) -> bool:
def join_url(base: str, *parts: str) -> str: def join_url(base: str, *parts: str) -> str:
""" """
Safely join a base URL and multiple path parts. Safely join a base URL with one or more path parts.
Handles double slashes while preserving the protocol '://'.
Uses urllib.parse to correctly handle scheme/netloc/query/fragment so that
only the path component is modified. Leading and trailing slashes are
stripped from each part before joining, preventing double-slash sequences
at segment boundaries without touching the scheme separator or query string.
Examples:
join_url("https://example.com/dav/", "/remote/", "file.pdf")
-> "https://example.com/dav/remote/file.pdf"
""" """
url = "/".join([base, *parts]) parsed = urlsplit(base)
url = url.replace("://", "$PLACEHOLDER$") # Strip each part once and filter out empty segments; use walrus operator
while "//" in url: # to avoid calling strip twice per iteration.
url = url.replace("//", "/") stripped_parts = [s for p in parts if (s := p.strip("/"))]
url = url.replace("$PLACEHOLDER$", "://") base_path = parsed.path.rstrip("/")
return url new_path = base_path + "/" + "/".join(stripped_parts) if stripped_parts else base_path
# Ensure path is non-empty so the reconstructed URL is valid.
if not new_path:
new_path = "/"
return urlunsplit((parsed.scheme, parsed.netloc, new_path, parsed.query, parsed.fragment))
+4 -6
View File
@@ -1,4 +1,3 @@
import os
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -7,13 +6,13 @@ from app.tasks.upload_to_nextcloud import upload_to_nextcloud
@pytest.fixture @pytest.fixture
def mock_settings(): def mock_settings(tmp_path):
with patch("app.tasks.upload_to_nextcloud.settings") as mock: with patch("app.tasks.upload_to_nextcloud.settings") as mock:
mock.nextcloud_upload_url = "http://nextcloud.local/" mock.nextcloud_upload_url = "http://nextcloud.local/"
mock.nextcloud_username = "testuser" mock.nextcloud_username = "testuser"
mock.nextcloud_password = "testpassword" mock.nextcloud_password = "testpassword"
mock.nextcloud_folder = "uploads" mock.nextcloud_folder = "uploads"
mock.workdir = "/tmp/workdir" mock.workdir = str(tmp_path)
mock.http_request_timeout = 30 mock.http_request_timeout = 30
yield mock yield mock
@@ -31,11 +30,10 @@ def mock_requests():
yield mock yield mock
def test_upload_to_nextcloud_url_construction(mock_settings, mock_requests): def test_upload_to_nextcloud_url_construction(tmp_path, mock_settings, mock_requests):
file_path = "/tmp/workdir/test_file.txt" file_path = str(tmp_path / "test_file.txt")
# Create dummy file # Create dummy file
os.makedirs("/tmp/workdir", exist_ok=True)
with open(file_path, "w") as f: with open(file_path, "w") as f:
f.write("test content") f.write("test content")