feat(12.1-01): repair Nextcloud adapter contract and add URL normalization
- Remove incompatible NextcloudBackend.list_folder(folder_path="") override so Nextcloud inherits canonical WebDAVBackend.list_folder(connection_id, user_id, parent_ref=None, page_token=None) -> CloudListing - Add normalize_nextcloud_url() to cloud_utils.py: idempotent canonical DAV root derivation, username percent-encoding, https-only, rejects userinfo/ query/fragment, validates via SSRF guard before return - Use normalize_nextcloud_url in cloud_backend_factory for nextcloud provider - Add TestNextcloudBackendNoListFolderOverride and TestNextcloudUrlNormalization to test_webdav_backend.py - All 9 Nextcloud contract failures now pass; full focused suite: 158 passed
This commit is contained in:
@@ -141,6 +141,97 @@ def _derive_fernet_key(master_key: bytes, user_id: str) -> Fernet:
|
||||
return Fernet(fernet_key)
|
||||
|
||||
|
||||
def normalize_nextcloud_url(base_url: str, username: str) -> str:
|
||||
"""Return the canonical Nextcloud WebDAV root URL for a given base URL and username.
|
||||
|
||||
The Nextcloud WebDAV root for a user is:
|
||||
https://<host>[/<subpath>]/remote.php/dav/files/<encoded_username>/
|
||||
|
||||
This helper is idempotent: if the URL is already canonical it is returned
|
||||
unchanged (no double-append of the WebDAV suffix).
|
||||
|
||||
Security contract:
|
||||
- Only https:// is accepted (rejects http, ftp, and bare origins).
|
||||
- userinfo, query strings, and fragments are rejected.
|
||||
- The username is percent-encoded as a single path segment.
|
||||
- The normalized URL is validated through validate_cloud_url() before return.
|
||||
- The normalized URL is never logged (it contains the username path segment).
|
||||
|
||||
Args:
|
||||
base_url: Nextcloud server URL. May be:
|
||||
- A bare origin: "https://nc.example.com"
|
||||
- Origin with trailing slash: "https://nc.example.com/"
|
||||
- Origin with deployment subpath: "https://example.com/nextcloud"
|
||||
- Already canonical DAV root: "https://nc.example.com/remote.php/dav/files/alice/"
|
||||
username: Nextcloud username. Will be percent-encoded as one path segment.
|
||||
|
||||
Returns:
|
||||
Canonical WebDAV root URL ending with "/".
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL uses http, contains userinfo/query/fragment,
|
||||
is malformed, or resolves to a private/internal address.
|
||||
"""
|
||||
from urllib.parse import quote as _quote, urlparse as _urlparse, urlunparse as _urlunparse
|
||||
|
||||
if not base_url:
|
||||
raise ValueError("Nextcloud base URL must not be empty.")
|
||||
|
||||
parsed = _urlparse(base_url)
|
||||
|
||||
# Reject non-HTTPS for Nextcloud (credentials travel over the connection)
|
||||
if parsed.scheme != "https":
|
||||
raise ValueError(
|
||||
f"Nextcloud URL must use https, got scheme {parsed.scheme!r}."
|
||||
)
|
||||
|
||||
# Reject userinfo component (e.g. user:pass@ in URL)
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError(
|
||||
"Nextcloud URL must not contain userinfo (user:pass@host). "
|
||||
"Pass credentials separately."
|
||||
)
|
||||
|
||||
# Reject query strings and fragments
|
||||
if parsed.query:
|
||||
raise ValueError(
|
||||
"Nextcloud URL must not contain a query string."
|
||||
)
|
||||
if parsed.fragment:
|
||||
raise ValueError(
|
||||
"Nextcloud URL must not contain a URL fragment."
|
||||
)
|
||||
|
||||
if not parsed.hostname:
|
||||
raise ValueError("Nextcloud URL has no hostname.")
|
||||
|
||||
# Build the canonical DAV path suffix
|
||||
encoded_user = _quote(username, safe="")
|
||||
dav_suffix = f"/remote.php/dav/files/{encoded_user}/"
|
||||
|
||||
# Determine the deployment subpath (everything between host and known DAV suffix)
|
||||
path = parsed.path.rstrip("/")
|
||||
|
||||
# Idempotence: strip the canonical suffix if already present
|
||||
dav_anchor = "/remote.php/dav/files/"
|
||||
if dav_anchor in path:
|
||||
# Already canonical or contains the suffix — normalise to canonical form
|
||||
idx = path.index(dav_anchor)
|
||||
subpath = path[:idx] # deployment prefix
|
||||
else:
|
||||
subpath = path
|
||||
|
||||
canonical_path = subpath.rstrip("/") + dav_suffix
|
||||
|
||||
# Reconstruct with scheme + host only (no port duplication; netloc handles port)
|
||||
canonical = _urlunparse((parsed.scheme, parsed.netloc, canonical_path, "", "", ""))
|
||||
|
||||
# Final SSRF validation — reject private/loopback/link-local targets
|
||||
validate_cloud_url(canonical)
|
||||
|
||||
return canonical
|
||||
|
||||
|
||||
def encrypt_credentials(master_key: bytes, user_id: str, credentials: dict) -> str:
|
||||
"""Encrypt a credentials dict to a Fernet token string.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user