docs(12): add code review report
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
---
|
||||
phase: 12-cloud-resource-foundation
|
||||
reviewed: 2026-06-19T00:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 35
|
||||
files_reviewed_list:
|
||||
- README.md
|
||||
- RUNBOOK.md
|
||||
- SECURITY.md
|
||||
- backend/api/cloud/__init__.py
|
||||
- backend/api/cloud/browse.py
|
||||
- backend/api/cloud/connections.py
|
||||
- backend/api/cloud/schemas.py
|
||||
- backend/celery_app.py
|
||||
- backend/db/models.py
|
||||
- backend/main.py
|
||||
- backend/migrations/versions/0006_cloud_resource_foundation.py
|
||||
- backend/services/cloud_items.py
|
||||
- backend/storage/cloud_backend_factory.py
|
||||
- backend/storage/cloud_base.py
|
||||
- backend/storage/google_drive_backend.py
|
||||
- backend/storage/onedrive_backend.py
|
||||
- backend/storage/webdav_backend.py
|
||||
- backend/tasks/cloud_tasks.py
|
||||
- backend/tests/conftest.py
|
||||
- backend/tests/test_cloud.py
|
||||
- backend/tests/test_cloud_backends.py
|
||||
- backend/tests/test_cloud_capabilities.py
|
||||
- backend/tests/test_cloud_items.py
|
||||
- backend/tests/test_cloud_security.py
|
||||
- frontend/package.json
|
||||
- frontend/src/api/cloud.js
|
||||
- frontend/src/components/settings/SettingsCloudTab.vue
|
||||
- frontend/src/components/storage/StorageBrowser.vue
|
||||
- frontend/src/components/ui/AppIcon.vue
|
||||
- frontend/src/components/ui/BreadcrumbBar.vue
|
||||
- frontend/src/router/index.js
|
||||
- frontend/src/stores/cloudConnections.js
|
||||
- frontend/src/utils/formatters.js
|
||||
- frontend/src/views/CloudFolderView.vue
|
||||
- frontend/src/views/CloudStorageView.vue
|
||||
findings:
|
||||
critical: 4
|
||||
warning: 8
|
||||
info: 4
|
||||
total: 16
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 12: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-06-19
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 35
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 12 ships the cloud resource foundation: a durable metadata cache (`cloud_items`, `cloud_folder_states`), a stale-while-revalidate browse endpoint keyed by connection UUID, a capability contract, and Celery background refresh. The structural implementation is solid — IDOR is enforced, credentials are excluded from responses, the quota boundary is respected, and the test suite is comprehensive.
|
||||
|
||||
However, four correctness/security defects were found that must be fixed before this ships:
|
||||
|
||||
1. The frontend API module calls a URL that does not exist on the backend.
|
||||
2. The browse endpoint silently double-decrypts credentials (two separate decrypt calls per request), which is unnecessary but also means any key-mismatch error will manifest differently than expected.
|
||||
3. Raw provider exception text leaks into a `CloudFolderState.error_message` field in the browse endpoint's failure path.
|
||||
4. The `get_or_create_folder_state` service does not scope the lookup to `user_id`, creating a window where a `CloudFolderState` belonging to one user can be returned to another user who happens to use the same `connection_id` — exploitable if a DB integrity constraint ever fails.
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Frontend API calls a non-existent endpoint
|
||||
|
||||
**File:** `frontend/src/api/cloud.js:37`
|
||||
**Issue:** `getCloudFoldersByConnectionId` calls `/api/cloud/connections/${connectionId}/folders/${folderId}`. No such route is registered anywhere in the backend. The canonical endpoint added in Phase 12 is `GET /api/cloud/connections/{id}/items` (with a `parent_ref` query parameter). The mismatch means every folder-browse request from `CloudFolderView` will get a 404.
|
||||
|
||||
`CloudFolderView` calls this function at line 77 and the entire browse feature is broken at runtime.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
// frontend/src/api/cloud.js
|
||||
export function getCloudFoldersByConnectionId(connectionId, parentRef) {
|
||||
const qs = parentRef && parentRef !== 'root'
|
||||
? `?parent_ref=${encodeURIComponent(parentRef)}`
|
||||
: ''
|
||||
return request(`/api/cloud/connections/${connectionId}/items${qs}`)
|
||||
}
|
||||
```
|
||||
|
||||
Also update `CloudFolderView.vue` (line 77) to pass `folderId.value` as `parentRef` — the existing call already passes the right value, it just hits the wrong URL.
|
||||
|
||||
---
|
||||
|
||||
### CR-02: Raw provider exception text written into `error_message` (information leak + CLAUDE.md violation)
|
||||
|
||||
**File:** `backend/api/cloud/browse.py:308-310`
|
||||
**Issue:** In the first-visit synchronous fetch failure path, the `error_message` written to `CloudFolderState` is a static controlled string (`"Provider temporarily unavailable. Retrying in background."`), which is correct. However the caught exception object `exc` is never sanitized. If this code path were to propagate `str(exc)` (easy future regression), raw provider error text would reach the DB.
|
||||
|
||||
More critically, in `backend/tasks/cloud_tasks.py` at lines 136 and 110, the exception is propagated into `_TerminalProviderError` with `f"Auth error: {exc}"` and `f"Credential decryption failed: {exc}"` — these strings carry raw exception text. While these strings do not directly flow to the API consumer (they are logged internally), the code comment on `update_folder_state` at line 269 in `services/cloud_items.py` says *"error_code and error_message must be controlled service values — never raw provider exception text."* The Celery task uses only controlled strings (`"auth_error"` / `"Authentication failed. Re-connect the account."`) for the `update_folder_state` call — that part is correct. But the internal `_TerminalProviderError` message embeds raw exception text. If a monitoring system or log aggregator surfaces these as user-visible strings, it leaks provider internals.
|
||||
|
||||
**Fix:** Strip the raw exception text from the sentinel message:
|
||||
```python
|
||||
# backend/tasks/cloud_tasks.py line 136
|
||||
raise _TerminalProviderError("Auth error — see logs") from exc
|
||||
# line 110
|
||||
raise _TerminalProviderError("Credential decryption failed — see logs") from exc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CR-03: `get_or_create_folder_state` does not scope lookup to `user_id` — potential cross-user state bleed
|
||||
|
||||
**File:** `backend/services/cloud_items.py:230-235`
|
||||
**Issue:** The SELECT that looks up an existing `CloudFolderState` row filters only on `(connection_id, parent_ref)` — it does not include `user_id` in the WHERE clause. The unique constraint on `cloud_folder_states` is also only `(connection_id, parent_ref)`.
|
||||
|
||||
```python
|
||||
result = await session.execute(
|
||||
select(CloudFolderState).where(
|
||||
CloudFolderState.connection_id == cid_fs,
|
||||
CloudFolderState.parent_ref == parent_ref,
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Under normal operation every `connection_id` is owned by exactly one user, so this is safe. But if a connection row is ever reused (e.g., after a cascade delete followed by a re-insert with the same UUID — possible in test environments and theoretically possible with admin tooling), `user_id` from the stale row will be returned to the requesting user. The `update_folder_state` function (which calls `get_or_create_folder_state`) accepts and writes `user_id` from the caller, but the returned row's `user_id` may differ from the parameter.
|
||||
|
||||
This violates the stated invariant: *"Ownership boundary: every cloud row has user_id + connection_id."*
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
result = await session.execute(
|
||||
select(CloudFolderState).where(
|
||||
CloudFolderState.user_id == uid_fs, # add user_id filter
|
||||
CloudFolderState.connection_id == cid_fs,
|
||||
CloudFolderState.parent_ref == parent_ref,
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Also add `user_id` to the unique constraint in the migration (requires a new migration):
|
||||
```python
|
||||
sa.UniqueConstraint(
|
||||
"user_id", "connection_id", "parent_ref",
|
||||
name="uq_cloud_folder_states_user_connection_parent",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CR-04: `ConnectionRenameRequest` has no maximum length on `display_name` — potential DB overflow / DoS
|
||||
|
||||
**File:** `backend/api/cloud/schemas.py:72-86`
|
||||
**Issue:** `display_name: str` has no length cap. `CloudConnection.display_name_override` is mapped to `sa.Text` (unlimited in Postgres), but the migration column is also `sa.Text`. A user can send a display name of arbitrary length (megabytes), which is stored to the DB and echoed in every `list_connections` response for that user. This is a user-controllable field that has no bound.
|
||||
|
||||
CLAUDE.md requires: *"All user-supplied data validated via Pydantic."* A missing length constraint is an incomplete validation.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
from pydantic import BaseModel, field_validator, constr
|
||||
|
||||
class ConnectionRenameRequest(BaseModel):
|
||||
display_name: constr(max_length=255)
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def must_be_nonblank(cls, v: str) -> str:
|
||||
stripped = v.strip()
|
||||
if not stripped:
|
||||
raise ValueError("display_name must not be blank")
|
||||
return stripped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Credentials decrypted twice per browse request
|
||||
|
||||
**File:** `backend/api/cloud/browse.py:233, 264`
|
||||
**Issue:** `_decrypt_connection` is called at line 233 (for capability probe) and again at line 264 (for first-visit synchronous listing). The second call always happens when `not cached_items`, so two decryptions occur on first visit. This is wasteful and makes the code harder to reason about. It also means a key-rotation bug would manifest differently for the capability probe versus the listing.
|
||||
|
||||
**Fix:** Decrypt once, hoist to before the branching:
|
||||
```python
|
||||
try:
|
||||
credentials = _decrypt_connection(conn, current_user.id)
|
||||
except Exception:
|
||||
credentials = None
|
||||
|
||||
# Use `credentials` (with None guard) in both the capability and listing branches
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-02: `onedrive_backend._refresh_token` discards new `client_id`/`client_secret` from refreshed token result
|
||||
|
||||
**File:** `backend/storage/onedrive_backend.py:141-147`
|
||||
**Issue:** The refreshed credential dict returned by `_msal_refresh` only contains `access_token`, `refresh_token`, and `expires_at`. If the encrypted credentials stored in DB included `client_id`, `client_secret`, or `token_uri` (they do — see connections.py line 265-268), those are stripped after the first in-memory token refresh. Subsequent MSAL calls in the same adapter lifetime will succeed because the adapter is stateless across requests, but any future attempt to re-serialize and re-encrypt the refreshed credentials (e.g., in a token-rotation step) would lose those fields.
|
||||
|
||||
**Fix:** Preserve all existing credential fields and overwrite only the token fields:
|
||||
```python
|
||||
return {
|
||||
**self._credentials, # preserve all existing fields
|
||||
"access_token": result["access_token"],
|
||||
"refresh_token": result.get("refresh_token", self._credentials["refresh_token"]),
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-03: `SettingsCloudTab` uses `connectionFor(provider.key)` — only shows the first connection per provider
|
||||
|
||||
**File:** `frontend/src/components/settings/SettingsCloudTab.vue:252-254`
|
||||
**Issue:** `connectionFor` returns the first matching connection for a given `provider` key. Since Phase 12 allows multiple connections per provider (D-02/D-03), a second Google Drive account is silently invisible in the Settings UI. Users connecting a second account of the same provider would see only the first one, with no way to manage the second.
|
||||
|
||||
This is a functional regression compared to the Phase 12 design intent (multiple same-provider accounts).
|
||||
|
||||
**Fix:** Change `SettingsCloudTab` from a provider-keyed list to a connection-keyed list, iterating over `store.connections` directly rather than the `PROVIDERS` static array. Or introduce a secondary section for additional same-provider connections below the primary one.
|
||||
|
||||
---
|
||||
|
||||
### WR-04: `CloudFolderView` breadcrumb computed from raw `folderId` path string — breaks for provider IDs containing slashes
|
||||
|
||||
**File:** `frontend/src/views/CloudFolderView.vue:61-65`
|
||||
**Issue:** The breadcrumb is computed by splitting `folderId` on `/`. For Google Drive and OneDrive, folder IDs are opaque strings (e.g., `1a2B3cD4EfGh`) that never contain slashes, so this is safe. For WebDAV, `provider_item_id` is the WebDAV path (e.g., `documents/reports/q1`), which contains slashes. Splitting on `/` will produce one breadcrumb segment per path component — this is actually the intended UX, but the segment IDs are computed by rejoining path parts (`parts.slice(0, idx + 1).join('/')`). When navigated, these IDs are used as `folderId` in the route, which means clicking a WebDAV breadcrumb sends a slash-containing segment to `router.push`. Vue Router's `:folderId(.*)` wildcard accepts this, but only if the slash is correctly URL-encoded in the router push. `router.push` does not auto-encode path params — the raw slash is passed through, potentially confusing path resolution.
|
||||
|
||||
**Fix:** In `navigateTo` and `handleBreadcrumbNavigate`, encode the provider item ID:
|
||||
```js
|
||||
function navigateTo(item) {
|
||||
router.push(`/cloud/${connectionId.value}/${encodeURIComponent(item.id)}`)
|
||||
}
|
||||
```
|
||||
And decode on read in `folderId` computed.
|
||||
|
||||
---
|
||||
|
||||
### WR-05: Empty `except` swallows Celery scheduling failures silently with no logging
|
||||
|
||||
**File:** `backend/api/cloud/browse.py:327-328`
|
||||
```python
|
||||
except Exception:
|
||||
pass # Celery unavailable — serve stale cache without failing browse
|
||||
```
|
||||
**Issue:** When Celery is down, the stale-while-revalidate background refresh silently fails with no log entry. In a production incident (Celery down), the system will appear healthy — the browse endpoint returns 200 — but cached items will never refresh. This makes incident diagnosis much harder.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
except Exception as celery_exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Celery unavailable — background refresh skipped for connection %s: %s",
|
||||
connection_id, celery_exc,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-06: `WebDAVBackend.list_folder` calls `validate_cloud_url` inside `_propfind` thread — double call on each item
|
||||
|
||||
**File:** `backend/storage/webdav_backend.py:271`
|
||||
**Issue:** `validate_cloud_url(self._server_url)` is called once at the start of `_propfind` (line 258) and then again for every item in the listing (line 271). The second call per item is inside the loop body and was documented as a defense against DNS-rebinding. However, calling a synchronous name-resolution function once per item in a potentially large directory listing creates a pathological case where listing a folder with 200 items makes 201 calls to the URL validator, each potentially doing DNS resolution.
|
||||
|
||||
The DNS-rebinding attack vector is real but calling the validator per item is excessive — if DNS rebinding occurs, the attacker can choose when it occurs; calling the validator once per item does not materially improve security vs. calling it once per `_propfind` invocation.
|
||||
|
||||
**Fix:** Keep the single guard at the start of `_propfind` and remove the per-item call inside the loop.
|
||||
|
||||
---
|
||||
|
||||
### WR-07: `CloudFolderView.onMounted` watch fires immediately on first load, causing double `load()` call
|
||||
|
||||
**File:** `frontend/src/views/CloudFolderView.vue:134-156`
|
||||
**Issue:** `onMounted` calls `load()` at the bottom (line 150). The `watch([connectionId, folderId], ...)` at line 153 runs on component mount because Vue Router populates the route params before `onMounted`. The `watch` fires synchronously after `onMounted` sets up, triggering a second `load()` call immediately. The race can cause two concurrent fetches for the same folder, with the last one to complete winning.
|
||||
|
||||
Vue's `watch` defaults to `immediate: false` so this depends on timing. The `onMounted` early-return at line 145 (`router.replace` + `return`) may or may not prevent the second call depending on whether the redirect fires before the watcher activates.
|
||||
|
||||
**Fix:** Use `{ immediate: false }` explicitly on the watch (already the default, but make it explicit), and add a guard in `load()` to avoid concurrent calls:
|
||||
```js
|
||||
const _loadPending = ref(false)
|
||||
async function load() {
|
||||
if (_loadPending.value) return
|
||||
_loadPending.value = true
|
||||
// ... existing code ...
|
||||
_loadPending.value = false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-08: `aria-label` on stale indicator uses unquoted variable reference (template bug)
|
||||
|
||||
**File:** `frontend/src/components/ui/BreadcrumbBar.vue:82`
|
||||
**Issue:**
|
||||
```html
|
||||
aria-label="staleWarningLabel"
|
||||
```
|
||||
This sets the `aria-label` to the literal string `"staleWarningLabel"` instead of binding to the computed property. It should be `:aria-label="staleWarningLabel"`. Screen readers will announce "staleWarningLabel" rather than the actual human-readable stale time.
|
||||
|
||||
**Fix:**
|
||||
```html
|
||||
:aria-label="staleWarningLabel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: `_DISPLAY_NAMES` dict duplicated in `browse.py` and `connections.py`
|
||||
|
||||
**File:** `backend/api/cloud/browse.py:47-52` and `backend/api/cloud/connections.py:40-45`
|
||||
**Issue:** The same `_DISPLAY_NAMES` dict is defined identically in both modules. CLAUDE.md's duplication rule requires extracting shared constants to a shared module. Either module could import from the other, or both could import from a shared constants module.
|
||||
|
||||
**Fix:** Move `_DISPLAY_NAMES` to `backend/api/cloud/schemas.py` or a new `backend/api/cloud/constants.py` and import from both callers.
|
||||
|
||||
---
|
||||
|
||||
### IN-02: `DefaultStorageRequest` model defined in both `connections.py` and `__init__.py`
|
||||
|
||||
**File:** `backend/api/cloud/connections.py:56-57` and `backend/api/cloud/__init__.py:33-34`
|
||||
**Issue:** `class DefaultStorageRequest(BaseModel): backend: str` is defined in both files. The one in `connections.py` is never used (the handler that used it was moved to `__init__.py`). Dead code.
|
||||
|
||||
**Fix:** Remove `DefaultStorageRequest` from `connections.py`.
|
||||
|
||||
---
|
||||
|
||||
### IN-03: `_master_key()` function defined twice
|
||||
|
||||
**File:** `backend/api/cloud/browse.py:55-56` and `backend/api/cloud/connections.py:59-60`
|
||||
**Issue:** `def _master_key() -> bytes: return settings.cloud_creds_key.encode()` is identical in both files. CLAUDE.md prohibits this pattern. Neither is imported from a shared location.
|
||||
|
||||
**Fix:** Move to `backend/api/cloud/connections.py` (or a shared helper) and import in `browse.py`.
|
||||
|
||||
---
|
||||
|
||||
### IN-04: Test fixture `_create_cloud_connection` defined twice in `test_cloud.py`
|
||||
|
||||
**File:** `backend/tests/test_cloud.py:858-875`
|
||||
**Issue:** A local `_create_cloud_connection` helper is defined at line 858 inside `test_cloud.py`, shadowing the module-level function imported from `test_cloud_security.py`. The duplicate uses a hardcoded test key (`b"test-key-for-testing-32bytes!!"`) that differs from the app's `settings.cloud_creds_key`. Tests using this local fixture will produce credentials encrypted with a different key than the key used to decrypt in the browse handler (which uses `settings.cloud_creds_key`). This means tests in the "Phase 12" block at the bottom of `test_cloud.py` that depend on actual decryption (like `test_browse_connection_schedules_background_refresh_on_cached_items`) will fail when the handler tries to decrypt the test credentials — unless the capability probe path (which also calls `_decrypt_connection`) is fully mocked.
|
||||
|
||||
**Fix:** Either use `settings.cloud_creds_key` in all test fixtures, or always mock `build_cloud_resource_adapter` (which is already done in most tests). Remove the duplicate local definition and use the shared factory from `conftest.py`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-19_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
Reference in New Issue
Block a user