- 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
272 lines
10 KiB
Python
272 lines
10 KiB
Python
"""
|
|
Cloud storage shared utilities for DocuVault.
|
|
|
|
Security design:
|
|
SSRF prevention (D-17): validate_cloud_url() resolves DNS via socket.getaddrinfo
|
|
*before* checking the resolved IP against blocked networks. This prevents DNS-rebinding
|
|
attacks where a hostname passes a string check but resolves to an internal IP.
|
|
It also explicitly blocks the string "localhost" before any DNS resolution.
|
|
|
|
HKDF credential encryption (D-18, CLOUD-02): _derive_fernet_key() creates a FRESH
|
|
HKDF instance on every call. The cryptography library raises AlreadyFinalized if
|
|
.derive() is called twice on the same instance (Pitfall 3 in RESEARCH.md). This
|
|
function avoids that by constructing a new HKDF(...) object each time.
|
|
|
|
References:
|
|
RESEARCH.md Pattern 2 — HKDF+Fernet
|
|
RESEARCH.md Pattern 6 — SSRF validation via ipaddress + socket.getaddrinfo
|
|
CLAUDE.md — cloud credentials encrypted with HKDF per-user key derivation
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import ipaddress
|
|
import json
|
|
import socket
|
|
from urllib.parse import urlparse
|
|
|
|
from cryptography.fernet import Fernet
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
|
|
|
|
# Networks that must never be the target of outbound cloud HTTP requests (D-17)
|
|
_BLOCKED_NETS = [
|
|
ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback
|
|
ipaddress.ip_network("169.254.0.0/16"), # Link-local (AWS/GCP metadata endpoint)
|
|
ipaddress.ip_network("10.0.0.0/8"), # RFC-1918 class A
|
|
ipaddress.ip_network("172.16.0.0/12"), # RFC-1918 class B
|
|
ipaddress.ip_network("192.168.0.0/16"), # RFC-1918 class C
|
|
ipaddress.ip_network("::1/128"), # IPv6 loopback
|
|
ipaddress.ip_network("fc00::/7"), # IPv6 Unique Local Address (ULA)
|
|
]
|
|
|
|
|
|
def validate_cloud_url(url: str) -> None:
|
|
"""Raise ValueError if the URL targets a private, internal, or restricted address.
|
|
|
|
Security contract (D-17):
|
|
1. Reject non-http/https schemes.
|
|
2. Reject URLs with no hostname.
|
|
3. Explicitly reject the string "localhost" before DNS resolution to avoid
|
|
cases where getaddrinfo behaviour varies by OS.
|
|
4. If the hostname is a raw IP address, check it directly.
|
|
Otherwise, resolve via socket.getaddrinfo (DNS lookup) and check the
|
|
resolved IP. This closes the DNS-rebinding window: the hostname must
|
|
resolve to a non-private IP *at validation time*.
|
|
5. Raise ValueError for any IP that falls inside a BLOCKED_NETS entry.
|
|
|
|
Called immediately before every outbound WebDAV/Nextcloud HTTP request,
|
|
not only at connect-time (RESEARCH.md Pitfall 5 — DNS rebinding mitigation).
|
|
|
|
Args:
|
|
url: The user-supplied WebDAV, Nextcloud, or cloud server URL.
|
|
|
|
Raises:
|
|
ValueError: If the URL uses a blocked scheme, has no hostname, or
|
|
resolves to a private/internal address.
|
|
"""
|
|
parsed = urlparse(url)
|
|
|
|
# Step 1: scheme check
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise ValueError(
|
|
f"Unsupported URL scheme '{parsed.scheme}': only http and https are allowed."
|
|
)
|
|
|
|
# Step 2: hostname presence
|
|
hostname = parsed.hostname # lowercased, brackets stripped for IPv6
|
|
if not hostname:
|
|
raise ValueError("URL has no hostname.")
|
|
|
|
# Step 3: explicit string block for 'localhost' (before DNS resolution)
|
|
if hostname == "localhost":
|
|
raise ValueError(
|
|
"URL targets localhost — this is a private/internal address."
|
|
)
|
|
|
|
# Step 4: resolve hostname to IP (or parse if already an IP literal)
|
|
try:
|
|
addr = ipaddress.ip_address(hostname)
|
|
except ValueError:
|
|
# Not a raw IP literal — resolve via DNS
|
|
try:
|
|
resolved = socket.getaddrinfo(hostname, None)[0][4][0]
|
|
addr = ipaddress.ip_address(resolved)
|
|
except socket.gaierror as exc:
|
|
raise ValueError(f"Cannot resolve hostname '{hostname}': {exc}") from exc
|
|
except (ValueError, IndexError) as exc:
|
|
raise ValueError(f"Unexpected error resolving '{hostname}': {exc}") from exc
|
|
|
|
# Step 5: check resolved IP against each blocked network
|
|
for net in _BLOCKED_NETS:
|
|
# Use try/except to handle IPv4/IPv6 family mismatch gracefully
|
|
try:
|
|
if addr in net:
|
|
raise ValueError(
|
|
f"URL targets a private/internal address: {addr} is in {net}"
|
|
)
|
|
except TypeError:
|
|
# Different address families (e.g. IPv4 addr in an IPv6 network) — skip
|
|
continue
|
|
|
|
|
|
def _derive_fernet_key(master_key: bytes, user_id: str) -> Fernet:
|
|
"""Derive a per-user Fernet encryption key using HKDF-SHA256.
|
|
|
|
Security notes:
|
|
- A FRESH HKDF instance is created on every call. The cryptography library
|
|
raises AlreadyFinalized if .derive() is called twice on the same instance.
|
|
Never cache or reuse the HKDF object (RESEARCH.md Pitfall 3).
|
|
- salt = user_id.encode() is deterministic (same user → same key), which
|
|
is required so that encrypt and decrypt produce consistent results.
|
|
- info = b"cloud-credentials" provides domain separation so the same
|
|
master_key cannot be used for unrelated HKDF derivations.
|
|
|
|
Args:
|
|
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
|
user_id: The authenticated user's UUID string (used as HKDF salt).
|
|
|
|
Returns:
|
|
A Fernet instance ready for encrypt/decrypt operations.
|
|
"""
|
|
hkdf = HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=user_id.encode("utf-8"),
|
|
info=b"cloud-credentials",
|
|
)
|
|
raw_key: bytes = hkdf.derive(master_key)
|
|
fernet_key = base64.urlsafe_b64encode(raw_key)
|
|
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.
|
|
|
|
The returned string is safe to store in the database credentials_enc column.
|
|
It is opaque base64 ciphertext — no plaintext fields are present.
|
|
|
|
Args:
|
|
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
|
user_id: The authenticated user's UUID string (HKDF salt).
|
|
credentials: A JSON-serialisable dict (access_token, refresh_token, etc.).
|
|
|
|
Returns:
|
|
A URL-safe base64 Fernet token (str).
|
|
"""
|
|
f = _derive_fernet_key(master_key, user_id)
|
|
plaintext = json.dumps(credentials).encode("utf-8")
|
|
return f.encrypt(plaintext).decode("utf-8")
|
|
|
|
|
|
def decrypt_credentials(master_key: bytes, user_id: str, credentials_enc: str) -> dict:
|
|
"""Decrypt a Fernet token back to the original credentials dict.
|
|
|
|
Args:
|
|
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
|
user_id: The authenticated user's UUID string (HKDF salt).
|
|
credentials_enc: The Fernet token string from the database.
|
|
|
|
Returns:
|
|
The original credentials dict.
|
|
|
|
Raises:
|
|
cryptography.fernet.InvalidToken: If the token is tampered with or
|
|
the wrong user_id (and thus wrong key) is used.
|
|
"""
|
|
f = _derive_fernet_key(master_key, user_id)
|
|
plaintext = f.decrypt(credentials_enc.encode("utf-8"))
|
|
return json.loads(plaintext)
|