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:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user