Files
kite/backend/storage/cloud_utils.py
T
curo1305 2b46f74329 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
2026-06-22 08:11:06 +02:00

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)