diff --git a/.planning/phases/13-virtual-local-cloud-operations/13-SECURITY.md b/.planning/phases/13-virtual-local-cloud-operations/13-SECURITY.md new file mode 100644 index 0000000..e1a0a41 --- /dev/null +++ b/.planning/phases/13-virtual-local-cloud-operations/13-SECURITY.md @@ -0,0 +1,151 @@ +--- +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:103–132); 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