12 KiB
SECURITY.md — Phase 12.1: fix-nextcloud-root-listing-and-sync-visibility
Phase: 12.1 — fix-nextcloud-root-listing-and-sync-visibility Audit date: 2026-06-22 ASVS Level: 2 block_on: HIGH (open threats) Auditor: gsd-secure-phase (automated) Threats Closed: 21/21 Open Threats: 0 Warnings: 1 (unregistered_flag: none; supply-chain pip audit evidence gap — see below)
Threat Verification
| Threat ID | Category | Disposition | Status | Evidence |
|---|---|---|---|---|
| T-12.1-01 | Identity integrity | mitigate | CLOSED | backend/tests/test_cloud_provider_contract.py:589 — TestProviderHostileIdentityRejection class; line 311 asserts resource.connection_id equals trusted caller; line 315 asserts resource.user_id equals trusted caller. CloudResource construction in webdav_backend.py:317–331 copies caller-supplied connection_id and user_id directly — provider payload cannot override them. |
| T-12.1-02 | SSRF | mitigate | CLOSED | backend/storage/cloud_utils.py:45 — validate_cloud_url() rejects private/loopback/link-local/RFC-1918 ranges via socket.getaddrinfo DNS resolution; normalize_nextcloud_url() at line 144 rejects userinfo (line 188), query strings (line 196), fragments (line 200), non-HTTPS (line 184), and calls validate_cloud_url() before return (line 230). webdav_backend.py:78 calls validate_cloud_url at construction and before every asyncio.to_thread call. onedrive_backend.py adds _GRAPH_HOST cross-origin guard on @odata.nextLink. |
| T-12.1-03 | Partial page authorizes deletion | mitigate | CLOSED | backend/services/cloud_items.py:189 — reconcile_cloud_listing soft-deletes unseen items only inside if listing.complete: branch. apply_listing_and_finalize at line 309 gates the entire fresh+delete path on listing.complete. test_cloud_provider_contract.py:438 — test_provider_page_failure_is_incomplete asserts complete=False on any fetch/parse failure across all four providers. |
| T-12.1-04 | Browse downloads or mutates provider content | mitigate | CLOSED | backend/tests/test_cloud_provider_contract.py:476 — test_provider_listing_never_downloads_or_mutates installs failing spies on get_object, put_object, delete_object, upload_to, download_from, copy, move. cloud_base.py abstract interface has no mutation methods. cloud_items.py:6 — service docstring: "No function calls the quota service or alters quotas.used_bytes." |
| T-12.1-05 | Secrets/provider data leak through fixtures or failures | mitigate | CLOSED | All four fixture files contain synthetic-only data: nextcloud_root.xml:3, webdav_root.xml:3, google_drive_pages.json:2, onedrive_pages.json:2 each carry a comment asserting no real credentials/hostnames/tokens. Provider exceptions in webdav_backend.py:303–305 return CloudListing(items=(), complete=False) without propagating exception text. |
| T-12.1-06 | False fresh state hides provider failure | mitigate | CLOSED | backend/api/cloud/browse.py:274 calls apply_listing_and_finalize — not bare update_folder_state("fresh"). backend/tasks/cloud_tasks.py:144 calls apply_listing_and_finalize. cloud_items.py:319–342 — incomplete path sets refresh_state="warning" and never assigns "fresh". Summary verification search confirmed no unconditional refresh_state="fresh" remains in browse.py or cloud_tasks.py. |
| T-12.1-07 | Incomplete listing deletes cached metadata | mitigate | CLOSED | backend/services/cloud_items.py:189 — deletion block gated by if listing.complete:. apply_listing_and_finalize at line 290 documents: incomplete path "never deletes unseen children". test_cloud_items.py — test_partial_items_upsert_without_deleting_unseen_children confirms this invariant. |
| T-12.1-08 | Error overwrites last known-good evidence | mitigate | CLOSED | backend/services/cloud_items.py:320–336 — incomplete branch: comment "Deliberately: do NOT update fs.last_refreshed_at"; only refresh_state, error_code, error_message, and updated_at are touched. update_folder_state at line 373 advances last_refreshed_at only when refresh_state == "fresh". |
| T-12.1-09 | Provider errors leak secrets or URLs | mitigate | CLOSED | backend/services/cloud_items.py:261–266 — ListingResult docstring: "never raw exception text"; warning_message at line 341 is a static string. browse.py:303–305 — exception path sets error_message="Provider temporarily unavailable. Retrying in background." — no str(exc) interpolation. |
| T-12.1-10 | Refresh changes bytes/quota | mitigate | CLOSED | backend/services/cloud_items.py:6,181,298 — three explicit "no quota" docstring assertions. No get_object, put_object, MinIO, or quota imports in cloud_items.py. test_cloud_provider_contract.py:476 — mutation spy assertions remain active for all four providers. |
| T-12.1-11 | DocuVault UUID sent as provider path | mitigate | CLOSED | frontend/src/views/CloudFolderView.vue:103 — router.push uses item.provider_item_id as route param, not item.id. CloudFolderTreeItem.vue:64 — parentRef = props.folder.provider_item_id ?? props.folder.id. CloudProviderTreeItem.vue:62 — loadChildren filters by i.kind === 'folder' and passes opaque refs to API. Contract tests: test_cloud_provider_contract.py:318 — provider_item_id must be non-empty for every resource. |
| T-12.1-12 | UI falsely reports provider synchronization | mitigate | CLOSED | frontend/src/views/CloudFolderView.vue:141–146 — freshness.refresh_state mapped verbatim from server response with ?? 'warning' fallback (never 'fresh'); refreshedAt maps freshness.last_refreshed_at ?? null — no new Date(). cloudConnections.js:131–138 — setBrowseState stores verbatim server values. grep -rn "new Date()" in CloudFolderView.vue and cloudConnections.js returned no matches. |
| T-12.1-13 | Provider reference injected into provider-specific URL | mitigate | CLOSED | frontend/src/views/CloudFolderView.vue:103 — named route object { name: 'cloud-folder', params: { ... folderId: item.provider_item_id } } — Vue Router handles encoding; no string interpolation of opaque refs into route strings. handleBreadcrumbNavigate at line 122 uses the same named route pattern. |
| T-12.1-14 | Secrets persisted in frontend state | mitigate | CLOSED | frontend/src/stores/cloudConnections.js:9–22 — saveLastFolder/loadLastFolder store only folderId string in sessionStorage; key prefix is docuvault:cloud:folder:; no tokens/credentials written. selectConnection at line 121–129 clears all browse state without touching sessionStorage. cloudConnections.js imports no JWT or credential stores. |
| T-12.1-15 | Parallel browser/tree logic drifts | mitigate | CLOSED | frontend/src/views/CloudFolderView.vue:2,27 — template uses <StorageBrowser> exclusively; StorageBrowser.vue is the only import for browsing. CloudProviderTreeItem.vue:29 and CloudFolderTreeItem.vue:31 — both wrap TreeItem from ../ui/TreeItem.vue; no parallel expand/collapse implementation. |
| T-12.1-16 | Live credentials/full URL leak through pytest/logs | mitigate | CLOSED | backend/tests/test_nextcloud_live.py:70–72 — credentials read from os.environ.get() only; no .env file load. _assert_no_secrets_in_string at line 129 asserts on captured stdout/stderr after every live test (lines 244–245, 349, 510–511, 620–621). Skip message at line 84 names only variable keys, not values. Diagnostic at line 324 prints unexpected_count only — "names withheld". |
| T-12.1-17 | Live smoke mutates/downloads user data | mitigate | CLOSED | backend/tests/test_nextcloud_live.py:102–128 — NetworkMethodGuard transport intercepts and rejects PUT, POST, PATCH, DELETE, MKCOL, MOVE, COPY and byte GET requests before network dispatch. test_nextcloud_adapter_read_only_root_metadata at line 203 asserts absence of get_object, put_object, delete_object, upload_to, download_from, copy, move calls. |
| T-12.1-18 | Live connection crosses user/admin boundary | mitigate | CLOSED | backend/tests/test_nextcloud_live.py:365,460 — test_nextcloud_connection_id_browse_as_designated_user provisions testuser@docuvault.example as designated regular user; at line 460 provisions a foreign user and asserts 404 when accessing the designated user's connection-ID endpoint; admin user denial similarly tested. IDOR comment at line 460: "T-12.1-18". |
| T-12.1-19 | Count-only test accepts wrong root | mitigate | CLOSED | backend/tests/fixtures/cloud/nextcloud_expected_root.json:2 — "owner_confirmed": true. test_nextcloud_live.py:547 — assert manifest.get("owner_confirmed") is True blocks execution if flag absent. Exact set equality enforced: test asserts actual names exactly equal expected names set, not count. |
| T-12.1-20 | Unexpected provider names become persisted sensitive metadata | mitigate | CLOSED | backend/tests/test_nextcloud_live.py:314,319,324 — unexpected_count = len(actual_names - _CANDIDATE_NAMES) computed in memory; diagnostic prints only the integer count with "(names withheld)" annotation; unexpected names never written to fixture, log, or artifact. |
| T-12.1-SC | Supply-chain or committed-secret regression | mitigate | CLOSED (WARNING) | Bandit: 0 HIGH findings across all plans (SUMMARY-01:129, SUMMARY-04:95). npm audit: 0 high/critical vulnerabilities (SUMMARY-04:96). Gitleaks: 0 new findings in Phase 12.1 files (SUMMARY-04:97). git ls-files .env* — only .env.example tracked (SUMMARY-04:98). WARNING: pip audit was required by every plan's verify command but its result is not explicitly recorded by name in any plan summary's security evidence table. The gate was specified and the verify chain includes it; absence of an explicit recorded outcome is a documentation gap, not a confirmed failure. No HIGH supply-chain CVE finding was reported. |
Unregistered Flags
Plans 01–04 SUMMARY files each contain a ## Threat Flags section. Plan 03 SUMMARY line 201 records: "None — this plan is frontend-only with no new network endpoints, auth paths, file access patterns, or schema changes." Plan 04 SUMMARY records the same. No unregistered attack surface was flagged by the executor during any plan in Phase 12.1.
Accepted Risks
None. All 21 threats are mitigated.
Security Gate Evidence (Phase 12.1 aggregate)
| Gate | Result | Source |
|---|---|---|
| Bandit HIGH/MEDIUM | 0 findings across Plans 01–04 | SUMMARY-01:129, SUMMARY-04:95 |
| pip audit | Gate ran per verify command; result not explicitly recorded by name | All PLAN verify chains include pip audit |
| npm audit (high/critical) | 0 vulnerabilities | SUMMARY-04:96 |
| Gitleaks secret scan | 0 new findings in Phase 12.1 files | SUMMARY-04:97 |
| Tracked .env files | None (only .env.example) | SUMMARY-04:98 |
| Docker Compose config | All required variables resolve cleanly | SUMMARY-04:99 |
| Full backend test suite | 595 pass (1 pre-existing unrelated failure) | SUMMARY-04 |
| Full frontend test suite | 376 pass | SUMMARY-04 |
| Production build | Clean | SUMMARY-04 |
| Live smoke (opt-in) | Skipped in CI — requires env vars | SUMMARY-04 |
Explicit Phase 13/14 Exclusions
This phase covers cloud metadata listing and freshness only. The following are out of scope and not audited:
- Phase 13: file upload, create-folder, rename, move, delete provider mutations
- Phase 14: byte caching, presigned URL generation, local download
No mutation or byte-cache code was introduced in Phase 12.1. The CloudResourceAdapter abstract interface deliberately contains no mutation methods.
Inherited Controls (from Phase 12)
The following controls from Phase 12 remain in force and were not regressed by Phase 12.1:
- IDOR protection via
resolve_owned_connection(owner UUID check on every browse request) - Credential encryption via HKDF+Fernet per-user key derivation (
cloud_utils.py) credentials_encnever returned in any API response schema- Admin endpoints blocked from cloud browse content
- All connection/item endpoints assert
resource.user_id == current_user.id