# Phase 12 Security Audit — Cloud Resource Foundation

**Audited:** 2026-06-22
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
**ASVS Level:** 2
**Plans audited:** 12-01, 12-02, 12-03, 12-04, 12-05, 12-06
**block_on:** critical

---

## Threat Verification

### Closed Threats

| Threat ID | Plan | Category | Disposition | Status | Evidence |
|-----------|------|----------|-------------|--------|----------|
| T-12-01 | 01/02/04 | IDOR | mitigate | CLOSED | `resolve_owned_connection` in `backend/services/cloud_items.py:51` gates on `CloudConnection.user_id == user_uuid`; `_get_owned_connection` in `connections.py:139` checks `conn.user_id != user_id`; `get_regular_user` dep on all cloud browse routes blocks admin tokens. Tests: `test_cloud_security.py:test_foreign_user_cannot_browse_cloud_item`, `test_foreign_user_cannot_browse_subfolder`, `test_foreign_user_cannot_delete_connection`, `test_admin_cannot_browse_cloud_connection`. |
| T-12-02 | 01 | Same-provider account collision | mitigate | CLOSED | `_insert_cloud_connection` (`connections.py:109`) always inserts a new UUID row — never upserts by provider. Unique constraint in migration 0006 is on `(connection_id, provider_item_id)` not `(user_id, provider)`. Tests: `test_cloud.py:test_same_provider_connections_are_independent` (line 331) asserts two distinct UUIDs; `test_cloud_security.py:test_same_provider_items_scoped_to_connection` proves DB-level isolation. |
| T-12-03 | 02/04 | Credential disclosure | mitigate | CLOSED | `CloudBrowseResponse`, `CloudItemOut`, `FolderFreshnessOut` schemas in `api/cloud/schemas.py` contain no credential fields. `CloudConnectionOut` in `api/schemas.py` is used on connection-list/update endpoints. Test: `test_cloud_security.py:test_browse_response_excludes_credentials_and_raw_fields` asserts `credentials_enc`, `access_token`, `refresh_token`, `client_secret` absent from response body. |
| T-12-04 | 02/04 | WebDAV SSRF | mitigate | CLOSED | `validate_cloud_url` called at `connections.py:385` (new WebDAV connection) and `connections.py:533` (credential update). `cloud_utils.py:validate_cloud_url` blocks RFC1918, link-local (169.254.x.x), file://, and GCP metadata endpoints. Test: `test_cloud_security.py:test_ssrf_url_validation_invariants` (parametrized, 5 bad URLs). |
| T-12-05 | 02 | Cache collision | mitigate | CLOSED | All `cloud_items.py` service functions scope by `connection_id`: `list_cloud_children` (line 82), `upsert_cloud_item` (line 114), `reconcile_cloud_listing` (line 194). Browse endpoint passes `cid_str = str(connection_id)` to all service calls. Unique index `uq_cloud_items_connection_provider_item` on `(connection_id, provider_item_id)` prevents cross-connection collision. |
| T-12-06 | 01 | Destructive capability probing | mitigate | CLOSED | `CloudResourceAdapter` in `storage/cloud_base.py` declares only `list_folder`, `get_capabilities`, and `merge_item_capabilities` — no put, delete, rename, move, or create_folder methods. Frozen dataclass contract enforces no mutation. Test: `test_cloud_capabilities.py` fake adapter asserts no mutation method calls (29 unit tests, 12-01 SUMMARY confirms). |
| T-12-07 | 01/02 | Partial-list metadata deletion | mitigate | CLOSED | `reconcile_cloud_listing` in `cloud_items.py:188` performs soft-deletion only under `if listing.complete:` guard. Failed or partial listings (`complete=False`) return without marking any rows deleted. Test: `test_cloud_items.py:test_refresh_cloud_folder_failed_refresh_retains_cached_rows`. |
| T-12-08 | 02/04 | Provider error XSS/leak | mitigate | CLOSED | Browse endpoint catch-all at `browse.py:300` emits only controlled strings (`"Provider temporarily unavailable. Retrying in background."`); raw exceptions are never propagated. Vue template auto-escaping is active on all components (no `v-html` in StorageBrowser or cloud components). Input validation: malformed UUID returns FastAPI 422 before any handler runs (`test_cloud_security.py:test_browse_malformed_connection_id_returns_422`). |
| T-12-09 | 01/02/04 | Quota corruption | mitigate | CLOSED | `cloud_items.py` has no import of the quota service and no write to `Quota.used_bytes`. Browse endpoint has no MinIO calls. Tests: `test_cloud_security.py:test_browse_no_quota_mutation` (re-reads quota row after browse and asserts unchanged), `test_browse_no_minio_calls` (mocks get_object/put/delete and asserts not called), `test_no_byte_download_during_browse` (adapter byte-download methods not called). |
| T-12-10 | 03 | Disabled action executes on keyboard/touch | mitigate | CLOSED | `StorageBrowser.vue:409` sets `'aria-disabled': isDisabled ? 'true' : undefined` (not native `disabled`). `CapabilityButton` component suppresses click, Enter, Space, and touchend for `unsupported` and `temporarily_unavailable` states. Test: `StorageBrowser.capabilities.test.js` — click, Enter, Space, and touchend suppression assertions. 12-03 SUMMARY confirms 323 frontend tests pass. |
| T-12-11 | 03 | Secret/token persistence | mitigate | CLOSED | `cloudConnections.js:16` stores only `folderId` (a provider path string) under key `docuvault:cloud:folder:{connectionId}` in `sessionStorage`. No JWT, credential field, or JSON object is stored. Test: `cloudConnections.test.js:149` — "sessionStorage stores only folder references, not tokens" asserts value is a plain string, not an object. |
| T-12-12 | 03 | UI source confusion | mitigate | CLOSED | `BreadcrumbBar` receives `connectionRoot` prop with connection display name — breadcrumb root is the connection name, not a generic "Cloud" label. Provider icon appears contextually in the connection root breadcrumb only, not repeated per row. `StorageBrowser` adds no per-row provider badge for cloud items. |
| T-12-05-01 | 05 | App starts against stale schema | mitigate | CLOSED | `docker-compose.yml` `migrate` service runs `alembic upgrade head`; all application services declare `condition: service_completed_successfully` on `migrate`. Test: `test_compose_migrations.py:7 passed`. |
| T-12-05-02 | 05 | DDL privilege escalation | mitigate | CLOSED | `migrate` service receives only `DATABASE_MIGRATE_URL` (DDL-capable role). `backend`, `celery-worker`, `celery-beat` receive only `DATABASE_URL` (DML-only). Test: `test_compose_migrations.py:test_migrate_service_uses_migrate_url`. |
| T-12-05-03 | 05 | Concurrent migrations | mitigate | CLOSED | Exactly one `migrate` service in Compose. Application replicas have `condition: service_completed_successfully` — they wait, never run Alembic. Test: `test_compose_migrations.py` asserts exactly one service runs `alembic upgrade head`. |
| T-12-05-04 | 05 | Secrets in Compose config or test output | mitigate | CLOSED | Compose uses `${VAR}` environment references; no rendered values in tracked files. Test: `test_compose_migrations.py` static assertions confirm env-var-only references. |
| T-12-05-05 | 05 | Integration test destroys developer DB | mitigate | CLOSED | `test_migration_0006.py` calls `pytest.skip` without `INTEGRATION=1`/`INTEGRATION_DATABASE_URL`. Never touches `DATABASE_URL`. |
| T-12-06-01 | 06 | IDOR while editing/browsing multiple connections | mitigate | CLOSED | `_get_owned_connection` at `connections.py:132` is called before every credential update, rename, delete, and config read. Returns 404 when `conn.user_id != user_id`. Test: `test_cloud.py:test_credential_update_wrong_owner_returns_404` (line 370). Browse path uses `resolve_owned_connection` (T-12-01 evidence applies). |
| T-12-06-02 | 06 | Credential disclosure through multi-account list/edit/errors | mitigate | CLOSED | `update_webdav_credentials` returns `CloudConnectionOut.model_validate(conn).model_dump()` — whitelist schema, no `credentials_enc` or decrypted fields. Merged credentials are written to DB only, never returned. Audit log at `connections.py:571` includes only `{"provider": conn.provider}`, no credential data. |
| T-12-06-03 | 06 | OAuth state confusion updates another connection | mitigate | CLOSED (by architectural exclusion) | OAuth callback always calls `_insert_cloud_connection` (always INSERT new UUID row). There is no "reconnect existing OAuth connection" flow in Phase 12 — editing OAuth accounts is deferred to Phase 13. The threat (state causes callback to overwrite a different connection's credentials) cannot occur because the callback never targets an existing connection ID. Redis state stores only `user_id` with 30-minute TTL; the callback re-validates user existence and activity before proceeding. |
| T-12-06-04 | 06 | SSRF bypass during credential edit | mitigate | CLOSED | `update_webdav_credentials` calls `validate_cloud_url(merged["server_url"])` at `connections.py:533` then `backend.health_check()` before `conn.credentials_enc = encrypt_credentials(...)`. No commit occurs if either check fails. |
| T-12-06-05 | 06 | Cross-connection metadata collision | mitigate | CLOSED | All service queries include both `user_id` and `connection_id` predicates (see T-12-05 evidence). Same unique constraint on `(connection_id, provider_item_id)` applies. Test: `test_cloud_security.py:test_same_provider_items_scoped_to_connection` inserts same `provider_item_id` under two connections and asserts two distinct rows exist. |
| T-12-06-06 | 06 | Route/provider identifier confusion | mitigate | CLOSED | `CloudProviderTreeItem.vue` calls `getCloudFoldersByConnectionId(connection.id, '')` (confirmed by 12-06 SUMMARY). `CloudFolderTreeItem.vue` accepts `connectionId` prop and uses it for all API calls and routes. Browse endpoint route `/connections/{connection_id}/items` is typed `uuid.UUID` — FastAPI rejects non-UUID slugs with 422. Test: `test_cloud_security.py:test_browse_malformed_connection_id_returns_422`. |
| T-12-SC | 04/06 | Supply-chain/secrets | mitigate | CLOSED | `bandit -r backend/ --severity-level medium`: 0 HIGH, 0 MEDIUM (12-04 SUMMARY). `npm audit --audit-level=high`: 0 vulnerabilities (12-06 SUMMARY). No new Python or npm runtime packages added in Phase 12. No hardcoded secrets detected. |
| T-12-05-SC | 05 | Dependency risk from gap closure | mitigate | CLOSED | No new runtime dependencies introduced in plan 05. Standard security gates rerun: Bandit 0 HIGH/MEDIUM, npm audit 0 high/critical. |
| T-12-06-SC | 06 | Dependency/supply-chain risk | mitigate | CLOSED | No new runtime dependencies in plan 06. npm audit 0 vulnerabilities (12-06 SUMMARY: 334 frontend tests pass, production build clean). |

---

## Unregistered Threat Flags

The following flags appeared in SUMMARY.md `## Threat Flags` sections with no formal threat ID:

| Flag | Source | Description | Assessment |
|------|--------|-------------|------------|
| `credential_in_worker` | 12-02-SUMMARY / `backend/tasks/cloud_tasks.py` | Credentials decrypted inside Celery worker — master key must be in `CLOUD_CREDS_KEY` env var on all worker nodes. | Informational — not a gap. Master key from env is the established pattern (`_master_key()` in connections.py:59). Workers share the same Compose env. No credentials in broker payload. |
| `sessionStorage-check` | 12-03-SUMMARY / `cloudConnections.js` | `saveLastFolder` stores only folder path string under namespaced key. | Informational — maps to T-12-11 (CLOSED). |

---

## Documentation Gap

**Finding (WARNING — not a BLOCKER):** The existing `SECURITY.md` Phase 12 threat register (written during plan 04) omits threats first introduced in plans 03 and 06: T-12-02, T-12-05, T-12-06, T-12-11, T-12-12, T-12-06-01 through T-12-06-06, T-12-05-SC, and T-12-06-SC. All mitigations are present in code; the omission is a documentation record gap, not an implementation gap. The project SECURITY.md at `SECURITY.md` should be extended with a "Phase 12 Plan 06 Closeout" section to close this gap.

---

## Summary

**Threats Closed:** 26/26
**ASVS Level:** 2
**Blockers:** 0
**Unregistered Flags:** 2 (both informational, both map to closed threats or accepted architecture decisions)

All declared Phase 12 threat mitigations are present in the implementation. No BLOCKER findings. The SECURITY.md documentation record requires a supplementary section to capture threat evidence from plans 03 and 06.
