Files

152 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 13
slug: virtual-local-cloud-operations
status: verified
threats_open: 0
asvs_level: 2
created: 2026-06-23
audited: 2026-06-23
---
# Phase 13 — Security: Virtual-Local Cloud Operations
**Audit date:** 2026-06-23
**Phase:** 13 — Virtual-Local Cloud Operations (plans 13-01 through 13-11)
**ASVS Level:** L2
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
**Phase scope:** connection health/reconnect/disconnect, authorized open/preview, sequential upload queue with typed conflict resolution, create-folder and rename with collision retry and stale guard, move with descendant safety and cross-connection block, delete with typed disclosure, metadata-only audit events across all 4 providers.
---
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → cloud API | Untrusted user input can trigger reconnect, content, and mutation operations |
| cloud API → provider SDK/HTTP | Provider failures and URLs must be normalized before reaching API responses |
| request transaction → audit log | Successful operations must log metadata only, with no secret or byte leakage |
| server health state → store/UI | Trusted backend status must not be replaced by client-side guesswork |
| user interaction → shared browser dialogs | Conflict and delete choices must remain explicit and auditable |
| router/service → provider | Provider exceptions and refreshed credentials must be normalized before leaving the backend boundary |
| service → database | Only orchestration code may persist refreshed credentials or trigger reconciliation |
| browser → reconnect/content routes | Untrusted requests must stay owner-scoped and CSRF-protected |
| cloud route → provider bytes | Provider content must stay behind DocuVault authorization and typed error shaping |
| browser upload → provider write | User content enters a provider-owned mutation path through DocuVault authorization only |
| provider SDK/HTTP → route result | Provider conflict, retry, and replace behavior must be normalized before Vue consumes it |
| upload success → metadata state | Only authoritative success may update cloud metadata or folder freshness |
| upload success → audit log | Only authoritative success may write an audit row, and it must stay metadata-only |
| typed backend result → shared queue UI | The client must consume authoritative conflict/error typing instead of inventing semantics |
| content helper → preview surface | Preview must stay behind DocuVault authorization |
| proposed new name → provider mutation | User-supplied names must be collision-safe, stale-safe, and provider-neutral before mutation |
| mutation success → metadata state | Only authoritative success may update cloud metadata and folder freshness |
| user-selected destination → provider mutation | Invalid folder destinations must be rejected before and after provider submission |
| delete success → audit trail | Only authoritative success may write metadata-only delete events |
| server health/mutation results → store/UI | The UI must present backend truth without inventing probe or mutation semantics |
| shared browser interactions → destructive actions | Delete and move UX must stay explicit and capability-aware |
| shipped code → documentation/security evidence | Closeout must describe only what actually shipped |
---
## Threat Register
| Threat ID | Threat | Category | Disposition | Status | Evidence |
|-----------|--------|----------|-------------|--------|----------|
| T-13-01 | IDOR — cloud mutation crosses user boundary | E | mitigate | CLOSED | `resolve_owned_connection` in `services/cloud_operations.py` — ownership asserted before any mutation; `test_foreign_user_cannot_browse_cloud_item`, `test_admin_cannot_browse_cloud_connection` pass |
| T-13-02 | Credential exposure in mutation response | I | mitigate | CLOSED | All mutation endpoints return `JSONResponse` with `CloudMutationResult`-shaped body; `credentials_enc`, `access_token`, `refresh_token` absent; `test_cloud_security.py::test_browse_response_excludes_credentials_and_raw_fields` passes |
| T-13-03 | SSRF via reconnect or upload URL | T | mitigate | CLOSED | WebDAV/Nextcloud upload and reconnect paths reuse `validate_cloud_url` from `storage.cloud_utils`; `test_ssrf_url_validation_invariants` passes |
| T-13-04 | Audit log contains provider bytes or credentials | I | mitigate | CLOSED | `write_audit_log` writes only `metadata_`-level fields (`kind`, `provider_item_id`, `display_name`, `byte_size`); `test_cloud_audit.py::test_upload_success_writes_metadata_only_audit_row`, `test_move_audit_contains_no_credentials`, `test_delete_audit_contains_no_bytes` |
| T-13-05 | Audit trail accuracy | R | mitigate | CLOSED | `write_audit_log` called only on authoritative success paths; non-success branches never reach audit; `test_cloud_audit.py::test_upload_conflict_does_not_write_false_overwrite_audit`, `test_rename_stale_does_not_write_false_rename_audit`, `test_delete_failed_does_not_write_false_audit_event` |
| T-13-06 | Cloud health UI — no background probe on navigation | T | mitigate | CLOSED | `CloudFolderView.vue` D-13 guard: `testConnection` called nowhere from navigation functions; `CloudFolderRenderedFlow.test.js::no_health_probe_on_folder_navigate_D13` |
| T-13-07 | Preview and download UI — no raw provider URLs | I | mitigate | CLOSED | `onFileOpen` calls `api.openCloudFile` (DocuVault-authorized); fallback uses `api.downloadCloudFile`; no `window.open()` with raw provider URL; `test_cloud_mutations.py::test_open_file_returns_authorized_download_url` |
| T-13-08 | Queue conflict decisions — explicit pause/resume | R | mitigate | CLOSED | StorageBrowser conflict dialog requires explicit `upload-queue-resolve` emit for all five actions; no silent path; `StorageBrowser.capabilities.test.js::test_queue_requires_explicit_resolution` |
| T-13-09 | Reconnect/consent UX — Google Drive broader scope | S | mitigate | CLOSED | `SettingsCloudTab.vue`: `data-test="gdrive-scope-notice"` with broader-scope copy; reconnect affordance present; `SettingsCloudTab.health.test.js::test_gdrive_scope_notice` |
| T-13-10 | Mutable contract layer — centralized kind/reason vocabulary | T | mitigate | CLOSED | `storage/cloud_base.py`: `MUT_KIND_*` and `MUT_REASON_*` constants; `MUT_KINDS = frozenset({…})` seals vocabulary; all providers import from here; `test_cloud_provider_contract.py` |
| T-13-11 | Credential plaintext at provider boundary | I | mitigate | CLOSED | Credentials decrypted only in `_resolve_and_get_adapter` (operations.py:103132); never returned in mutation results; `test_cloud_reconnect.py::test_reconnect_response_excludes_credentials` |
| T-13-12 | Reconciliation path — all writes via cloud_items.py | T | mitigate | CLOSED | All 4 provider backends contain zero calls to `upsert_cloud_item`, `reconcile_cloud_listing`, or `update_folder_state`; reconciliation exclusively in `api/cloud/operations.py` and `services/cloud_items.py` |
| T-13-13 | Health check exposes connection status to wrong user | E | mitigate | CLOSED | `POST /connections/{id}/test` asserts ownership via `resolve_owned_connection`; returns state/error fields only; `test_cloud_reconnect.py::test_health_check_wrong_owner_403` |
| T-13-14 | Reconnect stores tokens in browser/response | I | mitigate | CLOSED | `run_reconnect` persists refreshed credentials via `encrypt_credentials`; response is `{"status": "active"}` only; `test_cloud_reconnect.py::test_reconnect_persists_token_not_exposes_it` |
| T-13-15 | Drive broader-scope consent missing from UI | T | mitigate | CLOSED | `SettingsCloudTab.vue`: `data-test="gdrive-scope-notice"` with "all files in your Google Drive" copy; `SettingsCloudTab.health.test.js::test_gdrive_scope_notice` |
| T-13-16 | Silent overwrite on name conflict | T | mitigate | CLOSED | All 4 provider upload implementations return `conflict` kind with `reason: "name_conflict"`; StorageBrowser pauses queue; `test_cloud_backends.py::*_upload_conflict_*` |
| T-13-17 | Raw provider URL returned from open/download | I | mitigate | CLOSED | `/open/{item_id}` and `/download/{item_id}` proxy bytes through DocuVault; no `Location` header with provider URL; `test_cloud_mutations.py::test_open_file_never_returns_provider_url` |
| T-13-18 | WebDAV SSRF bypass through upload path | T | mitigate | CLOSED | `WebDAVBackend.upload` calls `validate_cloud_url` before submission; inherited by Nextcloud; `test_webdav_backend.py::test_upload_rejects_ssrf_url` |
| T-13-19 | Upload reconciliation bypass | T | mitigate | CLOSED | `run_upload` calls `upsert_cloud_item` + `update_folder_state` only on `MUT_KIND_UPLOADED`; conflict/offline/reauth bypass reconciliation; `test_cloud_mutations.py::test_upload_conflict_skips_reconcile` |
| T-13-20 | Audit payload secrecy (upload) | I | mitigate | CLOSED | `metadata_` dict contains only `kind`, `provider_item_id`, `display_name`, `byte_size`; `test_cloud_audit.py::test_upload_success_writes_metadata_only_audit_row` |
| T-13-21 | False success audit event on failure path | R | mitigate | CLOSED | Conflict, offline, reauth branches return before `write_audit_log`; `test_cloud_audit.py::test_upload_conflict_writes_no_audit_row` |
| T-13-22 | Queue resume flow implicit | T | mitigate | CLOSED | StorageBrowser requires explicit `upload-queue-resolve` for all five resolution actions; no silent paths; `StorageBrowser.capabilities.test.js::test_queue_requires_explicit_resolution` |
| T-13-23 | Preview/download UI exposes provider URL | I | mitigate | CLOSED | `onFileOpen` calls `api.openCloudFile`; `window.open()` to provider URL forbidden by D-02; `CloudFolderRenderedFlow.test.js::test_open_uses_open_cloud_file_api` |
| T-13-24 | Collision naming unbounded | T | mitigate | CLOSED | `run_create_folder` and `run_rename` use bounded retry loop (≤5 attempts) with `keep_both_name()`; exhaustion returns typed error; `test_cloud_mutations.py::test_create_folder_collision_exhaustion` |
| T-13-25 | Stale mutation creates duplicate on external change | T | mitigate | CLOSED | Stale guard calls `update_folder_state(refresh_state="warning")` and returns `stale` kind before provider submission; `test_cloud_mutations.py::test_create_folder_stale_guard_*`, `test_rename_stale_guard_*` |
| T-13-26 | Identity reconciliation missing after create/rename | T | mitigate | CLOSED | `run_create_folder` and `run_rename` call `upsert_cloud_item` with returned provider_item_id before returning success; `test_cloud_mutations.py::test_create_folder_reconciles_item`, `test_rename_reconciles_item` |
| T-13-27 | Move to descendant or cross-connection | T | mitigate | CLOSED | `run_move` checks descendant chain + self-move + connection-id equality before provider submission; `test_cloud_mutations.py::test_move_rejects_descendant_destination`, `test_move_rejects_cross_connection` |
| T-13-28 | Stale move operates on changed item | T | mitigate | CLOSED | Stale guard in `run_move` stops mutation, refreshes source folder state, returns `stale` kind; `test_cloud_mutations.py::test_move_stale_guard_*` |
| T-13-29 | Delete audit leaks bytes or credentials | I | mitigate | CLOSED | Typed delete results carry `is_folder`/`item_kind` only; audit metadata contains no token or byte; `test_cloud_audit.py::test_delete_audit_contains_no_bytes` |
| T-13-30 | Health UX auto-probes on navigate | T | mitigate | CLOSED | `testConnection` never called from navigation; `CloudFolderRenderedFlow.test.js::no_health_probe_on_folder_navigate_D13` |
| T-13-31 | Destructive cloud action without disclosure | R | mitigate | CLOSED | Delete requires `is_folder`/`supports_trash` disclosure; StorageBrowser renders permanent-delete warning for non-trash providers; `CloudFolderRenderedFlow.test.js::test_delete_shows_folder_warning` |
| T-13-32 | Google Drive broader scope no consent copy | I | mitigate | CLOSED | `data-test="gdrive-scope-notice"` with "all files in your Google Drive" copy in SettingsCloudTab; `SettingsCloudTab.health.test.js::test_gdrive_scope_notice` |
| T-13-33 | Closeout docs claim unshipped features | R | mitigate | CLOSED | Phase 13 closeout plan isolated to documentation/version/gate tasks only; no implementation work in plan 13-11 |
| T-13-34 | Secret scan skipped at release | I | mitigate | CLOSED | `gitleaks detect --redact` run; 3 pre-existing findings (all pre-Phase 13); no Phase 13 file findings |
---
## Security Gate Evidence
**Gate 1 — Full backend test suite:**
```
766 passed, 17 skipped, 4 deselected, 10 xfailed
(1 pre-existing failure: test_extract_docx — python-docx/libmagic not in container; unrelated to Phase 13)
```
**Gate 2 — Frontend test suite:**
```
429 passed (48 test files)
```
**Gate 3 — Bandit (backend static analysis):**
```
Total issues: Low: 11, Medium: 0, High: 0
(All High-confidence findings are Low-severity — pre-existing pattern; 0 HIGH severity findings)
```
**Gate 4 — npm audit:**
```
found 0 vulnerabilities
```
**Gate 5 — pip-audit:** Not runnable (Python 3.9 local env vs Python 3.12 requirements). No new packages in Phase 13. Existing packages audited in Phase 8 (0 critical/high CVEs). Security-critical packages pinned. See accepted risks.
**Gate 6 — Secret scan:**
```
gitleaks detect --redact: 3 findings — all pre-existing (pre-Phase 13 commits)
No findings in any Phase 13 file.
```
**Gate 7 — Owner/admin/credential-secrecy invariants:** All pass — IDOR block, admin block, no credentials in response, SSRF protection, audit secrecy, no-probe invariant.
**Gate 8 — docker compose config:** Resolves without error.
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| T-13-SC-pip | pip-audit tooling | pip-audit not runnable against Python 3.12 requirements in Python 3.9 local env. No new Python packages added in Phase 13; existing packages audited in Phase 8; same accepted gap as Phase 12 | gsd-security-auditor | 2026-06-23 |
| T-13-docx | test_extract_docx failure | ModuleNotFoundError: python-docx/libmagic not in container image. Pre-existing; unrelated to Phase 13 | gsd-security-auditor | 2026-06-23 |
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-06-23 | 34 | 34 | 0 | gsd-security-auditor (claude-sonnet-4-6) |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter