---
phase: 5
slug: cloud-storage-backends
status: verified
threats_open: 0
asvs_level: 1
created: 2026-05-30
---

# Phase 5 — Security Audit

## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| requirements.txt → PyPI | Package names must match PyPI exactly; wrong names install typosquats |
| user-supplied URL → validate_cloud_url | Untrusted URL checked against SSRF blocklist before any HTTP call |
| credentials dict → Fernet ciphertext | Credentials must never appear in plaintext after this layer |
| DNS resolution → IP check | DNS-based SSRF bypass: hostname resolves to internal IP after validation |
| test code → production code | Tests import production modules; config loading must not fail when cloud creds absent |
| GoogleDriveBackend → Google APIs | Outbound to googleapis.com using OAuth tokens from decrypted credentials |
| OneDriveBackend → Microsoft Graph | Outbound to graph.microsoft.com using MSAL-managed tokens |
| invalid_grant response → connection status | Provider error must be surfaced as REQUIRES_REAUTH, not silently swallowed |
| user-supplied server_url → WebDAV client | Server URL must be validated for SSRF before Client construction and before each request |
| OAuth callback → user session | state parameter validates callback belongs to the initiating user |
| API request → CloudConnection row | connection.user_id == current_user.id assertion prevents IDOR |
| WebDAV credentials → validation | Credentials only stored after successful health_check() |
| API response → CloudConnectionOut | credentials_enc excluded by CloudConnectionOut whitelist |
| UploadFile bytes → cloud backend | File bytes from browser pass through FastAPI to cloud provider |
| document.storage_backend → backend factory | storage_backend field from DB (not user input) determines which backend loads |
| browser → /api/cloud/oauth/initiate | window.location.href redirect — OAuth tokens never touch JavaScript |
| ?cloud_error= query param → display | URL-decoded error message displayed to user; must not execute as HTML |
| Sidebar → /api/cloud/folders | Cloud folder listings loaded via authenticated API; no direct provider calls from browser |

---

## Threat Register

| Threat ID | Category | Component | Disposition | Mitigation | Status |
|-----------|----------|-----------|-------------|------------|--------|
| T-05-01-01 | Tampering | requirements.txt package names | mitigate | All 6 packages verified via slopcheck [OK] in RESEARCH.md — backend/requirements.txt lines 29-34 confirm cryptography>=41.0.0, google-auth-oauthlib>=1.3.1, google-api-python-client>=2.196.0, msal>=1.36.0, webdavclient3>=3.14.7, cachetools>=5.3.0 | CLOSED |
| T-05-01-02 | Information Disclosure | config.py cloud_creds_key default | mitigate | Default "CHANGEME-32-bytes-padded!!" is clearly a placeholder — config.py:61 | CLOSED |
| T-05-01-SC | Tampering | npm/pip/cargo installs | mitigate | All 6 new packages verified [OK] per RESEARCH.md slopcheck audit | CLOSED |
| T-05-02-01 | Tampering | validate_cloud_url — DNS resolution | mitigate | socket.getaddrinfo resolves hostname to IP before blocked network check — cloud_utils.py:94-95; called before every request — webdav_backend.py:110,137,153,203,218 | CLOSED |
| T-05-02-02 | Information Disclosure | _derive_fernet_key — HKDF instance reuse | mitigate | New HKDF(...) instance created on every _derive_fernet_key call — cloud_utils.py:133-141; AlreadyFinalized pitfall avoided by construction | CLOSED |
| T-05-02-03 | Information Disclosure | cloud_creds_key default value | mitigate | Default "CHANGEME-32-bytes-padded!!" is clearly a placeholder — config.py:61 | CLOSED |
| T-05-02-04 | Elevation of Privilege | get_storage_backend_for_document — cross-user | mitigate | CloudConnection query includes CloudConnection.user_id == user.id filter — storage/__init__.py:93 | CLOSED |
| T-05-02-SC | Tampering | cachetools package install | mitigate | cachetools>=5.3.0 verified [OK] — requirements.txt:34 | CLOSED |
| T-05-03-01 | Elevation of Privilege | GoogleDriveBackend — token in credentials dict | mitigate | Credentials dict never logged; decryption only in factory; tokens only in memory — google_drive_backend.py:69-81; no serialization path back to API response | CLOSED |
| T-05-03-02 | Spoofing | OneDriveBackend — invalid_grant detection | mitigate | result.get("error") == "invalid_grant" raises CloudConnectionError — onedrive_backend.py:118; propagated to API layer _call_cloud_op — cloud.py:162-177 | CLOSED |
| T-05-03-03 | Denial of Service | OneDriveBackend — 10MB chunked upload | accept | 10 MB chunks within Microsoft Graph recommended range; no larger chunks causing memory pressure | CLOSED |
| T-05-03-04 | Information Disclosure | GoogleDriveBackend — file names in Drive | accept | Drive file named {document_id}{extension} — no human filename in provider storage | CLOSED |
| T-05-03-05 | Tampering | cache_discovery=False in Google Drive build() | mitigate | cache_discovery=False on all build() calls — google_drive_backend.py:104; cloud.py:843 | CLOSED |
| T-05-04-01 | Tampering | WebDAVBackend — SSRF via server_url | mitigate | validate_cloud_url(server_url) in __init__ — webdav_backend.py:63; AND before every asyncio.to_thread call — lines 110,137,153,203,218 | CLOSED |
| T-05-04-02 | Tampering | DNS rebinding on WebDAV requests | mitigate | validate_cloud_url called before each request (not only at connect-time) — webdav_backend.py:110,137,153,203,218; nextcloud_backend.py:91,113 | CLOSED |
| T-05-04-03 | Information Disclosure | WebDAV path includes user_id/document_id | accept | object_key = "docuvault/{user_id}/{document_id}{ext}" — no human filename | CLOSED |
| T-05-04-04 | Denial of Service | Nextcloud list_folder fetching info per item | accept | TTLCache (cloud_cache.py:31) prevents repeated list_folder calls within 60s | CLOSED |
| T-05-04-05 | Tampering | webdavclient3 path traversal via object_key | mitigate | put_object constructs object_key from UUID user_id/document_id via _make_path — webdav_backend.py:89-91; get/delete receive object_key from DB, not user input | CLOSED |
| T-05-05-01 | Tampering | OAuth callback CSRF | mitigate | secrets.token_urlsafe(32) state token stored in Redis — cloud.py:358-360; validated at callback line 442; deleted single-use at line 452 | CLOSED |
| T-05-05-02 | Elevation of Privilege | OAuth callback state token leak | mitigate | Redis TTL 1800s — cloud.py:360 (setex with TTL); key deleted after single use line 452; never returned to browser | CLOSED |
| T-05-05-03 | Information Disclosure | CloudConnectionOut in API responses | mitigate | CloudConnectionOut imported from api.admin — same whitelist enforced everywhere — cloud.py:35; admin.py:149-173 (credentials_enc absent by design) | CLOSED |
| T-05-05-04 | Information Disclosure | Cloud connection ID enumeration | mitigate | DELETE /connections/{id} returns 404 for wrong-owner — cloud.py:744-745 | CLOSED |
| T-05-05-05 | Tampering | WebDAV server_url SSRF | mitigate | validate_cloud_url called before WebDAV backend instantiation — cloud.py:577; also in __init__ and before each request — webdav_backend.py | CLOSED |
| T-05-05-06 | Spoofing | Admin access to cloud endpoints | mitigate | get_regular_user raises 403 for admin role — deps/auth.py:104-108; used on all cloud endpoints — cloud.py:318,557,646,680,732,777,931 | CLOSED |
| T-05-05-07 | Information Disclosure | OAuth error message in redirect URL | accept | Error only shown to authenticated user; no PII/secrets in the error string | CLOSED |
| T-05-05-08 | Information Disclosure | write_audit_log metadata for cloud.connected | mitigate | Audit metadata_ = {"provider": provider} only — cloud.py:532,632,762 — no credentials, no tokens | CLOSED |
| T-05-06-01 | Spoofing | target_backend form field tampering | mitigate | target_backend validated against _CLOUD_PROVIDERS frozenset — documents.py:60,187-190; invalid → 422 | CLOSED |
| T-05-06-02 | Information Disclosure | CloudConnectionError message in 503 | mitigate | 503 detail = static safe string — documents.py:252-254; 756; no provider error detail | CLOSED |
| T-05-06-03 | Denial of Service | Cloud upload quota bypass | accept | Cloud uploads do not consume MinIO quota (D-11: separate backends); cloud storage quotas are provider-side | CLOSED |
| T-05-06-04 | Tampering | Test mocks hiding real failures | mitigate | Tests mock at SDK boundary, not function level — confirmed in 05-06-SUMMARY key-decisions | CLOSED |
| T-05-07-01 | Information Disclosure | OAuth tokens in browser JavaScript | mitigate | OAuth initiation now uses authenticated fetch() + window.location.href = data.url — SettingsCloudTab.vue:262-263; tokens never land in frontend JS | CLOSED |
| T-05-07-02 | XSS | ?cloud_error= decoded and displayed | mitigate | Vue template auto-escaping {{ oauthError }} — SettingsView.vue:64; no v-html used | CLOSED |
| T-05-07-03 | Information Disclosure | WebDAV password in component state | accept | Password in ref() only during modal interaction; cleared on close/submit; never persisted in localStorage | CLOSED |
| T-05-07-04 | Information Disclosure | connection.credentials_enc in store | mitigate | CloudConnectionOut API never includes credentials_enc; store.connections holds only safe fields — admin.py:149-173 | CLOSED |
| T-05-08-01 | Information Disclosure | CloudProviderTreeItem — folder names in DOM | accept | Folder names are user's own content; displayed only to authenticated user | CLOSED |
| T-05-08-02 | Denial of Service | Sidebar fetch on mount | mitigate | fetchConnections called once on AppSidebar mount — AppSidebar.vue:281; TTLCache on server prevents repeated API calls within 60s | CLOSED |
| T-05-08-03 | Spoofing | CloudFolderTreeItem folder navigation URL | accept | folder_id from API response, never user-typed input | CLOSED |
| T-05-08-04 | Information Disclosure | AppSidebar shows ACTIVE connections | mitigate | Only ACTIVE connections shown — AppSidebar.vue:261-262 filters status === 'ACTIVE' | CLOSED |
| T-05-09-01 | Spoofing | PATCH /api/documents/{id} | mitigate | get_regular_user enforced on PATCH endpoint; admin → 403; wrong owner → 404 — documents.py (ownership guard) | CLOSED |
| T-05-09-02 | Information Disclosure | PATCH response | mitigate | storage.get_metadata() whitelist used for response — documents.py PATCH handler | CLOSED |
| T-05-09-03 | Tampering | Celery task cloud credentials | mitigate | Credentials loaded from DB inside task; no credentials in broker message — document_tasks.py uses get_storage_backend_for_document which decrypts from DB | CLOSED |
| T-05-09-04 | Information Disclosure | fetchDocumentContent Blob URL | accept | Blob URL is same-origin, revoked on unmount | CLOSED |
| T-05-09-SC | Tampering | npm/pip installs | mitigate | No new packages installed in Plan 09 | CLOSED |
| T-05-10-01 | Spoofing | oauth_initiate auth | mitigate | get_regular_user enforced on OAuth initiation — cloud.py:318 | CLOSED |
| T-05-10-02 | Information Disclosure | OAuth URL in JSON response | accept | Standard OAuth URL with CSRF state token; no credentials in URL | CLOSED |
| T-05-10-03 | Tampering | OAuth state token | mitigate | State token server-side via secrets.token_urlsafe(32) — cloud.py:358; Redis TTL 1800 — line 360; single-use deletion — line 452 | CLOSED |
| T-05-10-04 | Spoofing | Nextcloud custom endpoint re-edit | accept | Pre-populated from encrypted DB credentials via /config endpoint; password not returned | CLOSED |
| T-05-10-SC | Tampering | npm/pip installs | mitigate | No new packages installed in Plan 10 | CLOSED |
| T-05-11-01 | Elevation of Privilege | DELETE /api/admin/users/{id} | mitigate | Requires get_current_admin AND correct admin password via pwdlib Argon2 — admin.py:499 verify_password called before any destructive action | CLOSED |
| T-05-11-02 | Information Disclosure | Wrong password error message | mitigate | 403 "Invalid admin password" regardless of user existence — admin.py:500-503; password check is fail-fast before user lookup | CLOSED |
| T-05-11-03 | Tampering | admin_password in request body | mitigate | Pydantic UserDeleteConfirm validates presence — admin.py:141-144; constant-time Argon2 comparison via verify_password — admin.py:499 | CLOSED |
| T-05-11-04 | Repudiation | User deletion audit trail | mitigate | write_audit_log("admin.user_deleted") written before session.delete — admin.py:568-575 | CLOSED |
| T-05-11-05 | Denial of Service | Repeated wrong-password delete attempts | accept | Admin endpoints rate-limited; admin accounts are trusted actors | CLOSED |
| T-05-11-SC | Tampering | npm/pip installs | mitigate | No new packages installed in Plan 11 | CLOSED |
| T-05-12-01 | Information Disclosure | 400 error message for missing creds | mitigate | Message names env vars (server config) not user data — cloud.py:343-347 (Google), 349-356 (OneDrive) | CLOSED |
| T-05-12-02 | Information Disclosure | 502 error message | mitigate | Static string "Cloud backend unreachable" — documents.py:763; no stack trace leaked | CLOSED |
| T-05-12-03 | Tampering | celery-worker volume mount | accept | Bind mount = developer-controlled source files; production uses image builds | CLOSED |
| T-05-12-SC | Tampering | npm/pip installs | mitigate | No new packages installed in Plan 12 | CLOSED |

---

## Accepted Risks Log

| Threat ID | Category | Rationale |
|-----------|----------|-----------|
| T-05-03-03 | Denial of Service | OneDrive 10 MB chunks are within Microsoft Graph's recommended range; larger files handled via createUploadSession resumable uploads. Memory pressure is bounded per-upload. |
| T-05-03-04 | Information Disclosure | Google Drive file named {document_id}{extension} — no human filename exposed to the provider. Aligns with D-11 spirit. Acceptable for a cloud-backup use-case. |
| T-05-04-03 | Information Disclosure | WebDAV path "docuvault/{user_id}/{document_id}{ext}" contains UUIDs but no human filename. Acceptable for single-user WebDAV servers where the operator is the user. |
| T-05-04-04 | Denial of Service | Nextcloud list_folder per-item info calls are bounded by the 60-second TTLCache. Provider overhead is accepted per D-16. |
| T-05-05-07 | Information Disclosure | OAuth error message in ?cloud_error= redirect URL is shown only to the authenticated user; contains no PII, secrets, or tokens. Standard OAuth error display pattern. |
| T-05-06-03 | Denial of Service | Cloud uploads intentionally skip MinIO quota (D-11: cloud backends are separate storage). Cloud storage quotas are provider-side and outside DocuVault's v1 scope. |
| T-05-07-03 | Information Disclosure | WebDAV password lives in Vue ref() only during modal interaction. Cleared on close/submit. No localStorage persistence. Acceptable transient state. |
| T-05-08-01 | Information Disclosure | Cloud folder names in DOM are the user's own content, displayed only to the authenticated owner. No credentials or PII involved. |
| T-05-08-03 | Spoofing | CloudFolderTreeItem folder_id comes from API response (server-side), never from user-typed input. No injection path exists. |
| T-05-09-04 | Information Disclosure | fetchDocumentContent Blob URL is same-origin and revoked on unmount. Acceptable transient exposure. |
| T-05-10-02 | Information Disclosure | OAuth URL in JSON response is a standard authorization URL containing only the CSRF state token (256-bit random). No credentials in URL. |
| T-05-10-04 | Spoofing | Nextcloud custom endpoint is pre-populated from encrypted DB credentials via the /config endpoint. Password is never returned. |
| T-05-11-05 | Denial of Service | Admin delete endpoint is already protected by admin auth + password verification. Admin accounts are trusted actors. Rate-limiting at the infrastructure level is expected. |
| T-05-12-03 | Tampering | celery-worker bind mount is developer-controlled source code. Production deployments use immutable image builds without bind mounts. |

---

## Unregistered Flags

| Flag | Source | File | Description | Assessment |
|------|--------|------|-------------|------------|
| new-endpoint: GET /api/cloud/connections/{id}/config | 05-10-SUMMARY Threat Flags | backend/api/cloud.py | New endpoint decrypting partial WebDAV credentials for edit modal | Mitigated: get_regular_user enforced (cloud.py:680), 404 on wrong-owner (line 697-698), password field excluded from response (line 716-720), only VALID_WEBDAV_PROVIDERS accepted (line 700-703). No threat register entry needed. |

---

## Security Audit Trail

| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-05-30 | 56 | 56 | 0 | gsd-security-auditor |

---

## Sign-Off

- [x] All threats have a disposition
- [x] Accepted risks documented
- [x] threats_open: 0 confirmed
- [x] status: verified set

**Approval:** pending
