docs(06.2): add code review report
This commit is contained in:
+237
-264
@@ -1,139 +1,118 @@
|
||||
---
|
||||
phase: 06.2-close-v1-sharing-cloud-delete-csv-export-gaps
|
||||
reviewed: 2026-05-31T00:00:00Z
|
||||
reviewed: 2026-05-31T12:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 9
|
||||
files_reviewed: 27
|
||||
files_reviewed_list:
|
||||
- backend/api/admin.py
|
||||
- backend/api/audit.py
|
||||
- backend/api/documents.py
|
||||
- backend/api/shares.py
|
||||
- backend/services/storage.py
|
||||
- backend/tests/test_admin_api.py
|
||||
- backend/tests/test_audit.py
|
||||
- backend/tests/test_constant_time_auth.py
|
||||
- backend/tests/test_documents.py
|
||||
- backend/tests/test_quota.py
|
||||
- backend/tests/test_security.py
|
||||
- backend/tests/test_security_headers.py
|
||||
- backend/tests/test_shares.py
|
||||
- backend/tests/test_totp_replay.py
|
||||
- frontend/src/api/client.js
|
||||
- frontend/src/components/admin/AdminUsersTab.vue
|
||||
- frontend/src/components/admin/AuditLogTab.vue
|
||||
- frontend/src/components/admin/__tests__/AdminAiConfigTab.test.js
|
||||
- frontend/src/components/admin/__tests__/AdminQuotasTab.test.js
|
||||
- frontend/src/components/admin/__tests__/AdminUsersTab.test.js
|
||||
- frontend/src/components/auth/__tests__/PasswordStrengthBar.test.js
|
||||
- frontend/src/components/documents/DocumentCard.vue
|
||||
- frontend/src/components/sharing/ShareModal.vue
|
||||
- frontend/src/stores/__tests__/auth.test.js
|
||||
- frontend/src/stores/documents.js
|
||||
- frontend/src/views/AccountView.vue
|
||||
- frontend/src/views/CloudFolderView.vue
|
||||
findings:
|
||||
critical: 4
|
||||
warning: 6
|
||||
info: 4
|
||||
total: 14
|
||||
critical: 7
|
||||
warning: 8
|
||||
info: 5
|
||||
total: 20
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 06.2: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-05-31T00:00:00Z
|
||||
**Reviewed:** 2026-05-31T12:00:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 9
|
||||
**Files Reviewed:** 27
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
This phase closes v1 gaps across document sharing, cloud delete, and audit/CSV-export
|
||||
functionality. The backend audit API is well-structured with appropriate admin guards,
|
||||
parameterized queries, and a whitelist serializer that correctly prevents sensitive field
|
||||
leakage. The share and document test coverage is comprehensive. However, four critical
|
||||
bugs were found that either break features silently in production or expose runtime
|
||||
crashes, plus six warnings covering security hygiene and robustness.
|
||||
Phase 06.2 closes v1 gaps across document sharing (SHARE-03, SHARE-05), cloud-delete propagation, admin audit log (ADMIN-06), and CSV export. This review covers the full 27-file scope including backend APIs, services, frontend stores and views, and backend/frontend test suites.
|
||||
|
||||
The core security invariants are consistently implemented: every document endpoint asserts `resource.user_id == current_user.id`, the admin whitelist serializer (`_user_to_dict`, `_doc_to_dict`, `_audit_to_dict_with_handles`) correctly excludes sensitive fields, and the sharing IDOR protections (`owner_id == current_user.id`) are in place for both PATCH and DELETE on shares.
|
||||
|
||||
Seven blocker-level issues were found:
|
||||
|
||||
1. **Audit log event-type filter is silently broken** — the frontend sends category prefixes (`"auth"`, `"document"`) but the backend does exact-match against dot-namespaced types (`"auth.login"`, `"document.uploaded"`). Every filter selection returns zero results.
|
||||
2. **`download_daily_export` crashes on non-MinIO deployments** — no `isinstance` guard before accessing `backend._client`.
|
||||
3. **CSV export serializes `metadata_` as Python repr** — `csv.DictWriter` calls `str()` on the dict, producing `{'key': val}` instead of valid JSON.
|
||||
4. **Three audit CSV/download functions bypass the 401-refresh-retry path** — session expiry silently breaks exports without session recovery.
|
||||
5. **UUID format mismatch in quota SQL** — `confirm_upload` strips dashes from the UUID before the SQL bind parameter, while PostgreSQL expects standard dashed UUID format; quota enforcement is unreliable.
|
||||
6. **`Content-Disposition` filename is not RFC 5987-encoded** — special characters in user-supplied filenames can inject extra header fields.
|
||||
7. **`PATCH /api/shares/{share_id}` writes no audit log** — permission escalations on shares are unrecorded.
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: AuditLogTab event_type filter always returns zero results — feature non-functional
|
||||
### CR-01: Audit log event-type filter always returns zero results — feature non-functional
|
||||
|
||||
**File:** `frontend/src/components/admin/AuditLogTab.vue:37-41`
|
||||
|
||||
**Issue:** The filter dropdown submits bare category prefixes (`"auth"`, `"document"`,
|
||||
`"folder"`, `"share"`, `"admin"`) to the backend. The backend applies exact-equality
|
||||
filtering (`AuditLog.event_type == event_type` — `backend/api/audit.py:120, 159, 284`).
|
||||
Every actual event type in the codebase uses dot-notation:
|
||||
`"auth.login"`, `"auth.logout"`, `"document.uploaded"`, `"document.deleted"`,
|
||||
`"share.granted"`, `"share.revoked"`, `"admin.user_created"`, `"admin.quota_changed"`, etc.
|
||||
|
||||
An exact match of `"auth"` against `"auth.login"` never fires. Selecting any category
|
||||
from the dropdown silently returns an empty list instead of filtered results — the entire
|
||||
filter feature is non-functional.
|
||||
|
||||
**Fix:** Change the backend filter from exact equality to a prefix `LIKE` match (most
|
||||
flexible) or update the dropdown to send exact event-type strings:
|
||||
**Issue:** The filter `<select>` emits bare category strings (`"auth"`, `"document"`, `"folder"`, `"share"`, `"admin"`). The backend applies exact equality (`AuditLog.event_type == event_type`) at `backend/api/audit.py:120, 159, 284`. All actual event types use dot-namespaced format: `"auth.login"`, `"document.uploaded"`, `"share.granted"`, `"admin.user_created"`, etc. An exact match of `"auth"` against `"auth.login"` never fires. Selecting any category silently returns an empty list — the entire filter feature is non-functional.
|
||||
|
||||
**Fix (preferred — change backend to prefix match):**
|
||||
```python
|
||||
# backend/api/audit.py — at all three filter call sites (lines 120, 159, 284):
|
||||
# backend/api/audit.py — lines 120, 159, and the count_q block at 283-284:
|
||||
if event_type is not None:
|
||||
q = q.where(AuditLog.event_type.like(f"{event_type}%"))
|
||||
```
|
||||
|
||||
```python
|
||||
# Also fix the count_q inline block in list_audit_log (lines 283-284):
|
||||
if event_type is not None:
|
||||
count_q = count_q.where(AuditLog.event_type.like(f"{event_type}%"))
|
||||
**Fix (alternative — use exact event-type strings in the frontend):**
|
||||
```html
|
||||
<option value="auth.login">Login</option>
|
||||
<option value="document.uploaded">Document uploaded</option>
|
||||
<option value="document.deleted">Document deleted</option>
|
||||
<option value="share.granted">Share granted</option>
|
||||
<option value="share.revoked">Share revoked</option>
|
||||
<option value="admin.user_created">Admin: user created</option>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CR-02: `download_daily_export` accesses `backend._client` without a MinIOBackend guard — AttributeError crash on non-MinIO deployments
|
||||
### CR-02: `download_daily_export` accesses `backend._client` without MinIOBackend guard — AttributeError on non-MinIO deployments
|
||||
|
||||
**File:** `backend/api/audit.py:219-228`
|
||||
|
||||
**Issue:** `list_daily_exports` (line 182) correctly checks
|
||||
`isinstance(backend, MinIOBackend)` and returns an empty list for non-MinIO storage.
|
||||
`download_daily_export` (line 219) calls `get_storage_backend()` and immediately uses
|
||||
`backend._client` with no type guard. On deployments where the primary storage backend
|
||||
is Google Drive, OneDrive, or Nextcloud, `backend._client` does not exist on those
|
||||
classes, raising `AttributeError`. The broad `except Exception` on line 232 catches
|
||||
this and re-raises it as a 404, masking a real misconfiguration entirely — the admin
|
||||
sees "Export not found" when the real problem is a storage type mismatch.
|
||||
|
||||
**Fix:** Mirror the guard from `list_daily_exports`:
|
||||
**Issue:** `list_daily_exports` (line 182) correctly guards with `isinstance(backend, MinIOBackend)` and returns empty for non-MinIO storage. `download_daily_export` at line 219 calls `get_storage_backend()` and directly accesses `backend._client` with no type guard. On Google Drive, OneDrive, Nextcloud, or WebDAV storage backends, `_client` does not exist — an `AttributeError` is raised and swallowed by the broad `except Exception` at line 232, returning a misleading 404 "Export not found" to the admin.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
@router.get("/audit-log/daily-exports/{date}")
|
||||
async def download_daily_export(
|
||||
date: str,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
|
||||
raise HTTPException(status_code=404, detail="Invalid date format")
|
||||
|
||||
backend = get_storage_backend()
|
||||
if not isinstance(backend, MinIOBackend): # <-- add this guard
|
||||
raise HTTPException(status_code=404, detail="Export not found")
|
||||
|
||||
key = f"audit-logs/{date}.csv"
|
||||
...
|
||||
backend = get_storage_backend()
|
||||
if not isinstance(backend, MinIOBackend):
|
||||
raise HTTPException(status_code=404, detail="Export not found")
|
||||
key = f"audit-logs/{date}.csv"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CR-03: CSV export writes raw Python `dict` repr for `metadata_` — not valid JSON
|
||||
### CR-03: CSV export serializes `metadata_` as Python repr — not valid JSON
|
||||
|
||||
**File:** `backend/api/audit.py:372`
|
||||
|
||||
**Issue:** `csv.DictWriter.writerow()` receives the dict returned by
|
||||
`_audit_to_dict_with_handles()`, which includes `"metadata_": entry.metadata_` where
|
||||
`entry.metadata_` is a Python `dict` (SQLAlchemy deserializes `JSONB` columns to native
|
||||
Python objects). `csv.DictWriter` calls `str()` on values it cannot serialize natively,
|
||||
producing Python repr syntax with single quotes (`{'size_bytes': 100}`) rather than
|
||||
valid JSON (`{"size_bytes": 100}`). Any downstream system that tries to parse the
|
||||
`metadata_` CSV column as JSON will fail.
|
||||
|
||||
Reproduced:
|
||||
```python
|
||||
import csv, io
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=["metadata_"])
|
||||
writer.writeheader()
|
||||
writer.writerow({"metadata_": {"size_bytes": 100}})
|
||||
# output: "{'size_bytes': 100}" ← Python repr, NOT JSON
|
||||
```
|
||||
**Issue:** `csv.DictWriter.writerow()` calls `str()` on values it cannot natively serialize. `entry.metadata_` is a Python `dict` (SQLAlchemy deserializes JSONB to native Python), producing `{'size_bytes': 100}` — Python repr with single quotes — rather than valid JSON `{"size_bytes": 100}`. Any downstream consumer that parses the `metadata_` column as JSON will fail. The test `test_audit_log_export_csv` does not assert on the cell content so this bug passes the test suite.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
import json
|
||||
|
||||
# In export_audit_log(), at the writerow call site (line 372):
|
||||
for row in rows:
|
||||
entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]
|
||||
record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val)
|
||||
@@ -141,135 +120,142 @@ for row in rows:
|
||||
writer.writerow(record)
|
||||
```
|
||||
|
||||
The `test_audit_log_export_csv` test does not assert the content of the metadata cell
|
||||
so this bug currently passes all tests while corrupting exported data.
|
||||
|
||||
---
|
||||
|
||||
### CR-04: Three admin fetch functions lack 401-refresh-retry — silently fail on token expiry
|
||||
### CR-04: Three audit export functions bypass 401-refresh-retry — exports silently break on token expiry
|
||||
|
||||
**File:** `frontend/src/api/client.js:398-483`
|
||||
|
||||
**Issue:** `adminExportAuditLogCsv`, `adminListDailyExports`, and
|
||||
`adminDownloadDailyExport` all use raw `fetch()` calls without the 401-refresh-then-retry
|
||||
logic that `request()` (line 27-30) and `fetchDocumentContent()` (lines 520-529) both
|
||||
implement. When the 15-minute access token expires while an admin is on the audit log
|
||||
page, all three functions immediately throw (e.g. `Error("Export failed: 401")`) with
|
||||
no session recovery. The user's session is still valid (they have a live refresh token)
|
||||
but the export/list operations fail hard. The auth store is not cleared, so the user
|
||||
cannot tell why the operation failed.
|
||||
|
||||
Note: `adminListDailyExports` returns JSON and could be routed through `request()`
|
||||
directly, which already has the retry logic.
|
||||
**Issue:** `adminExportAuditLogCsv`, `adminListDailyExports`, and `adminDownloadDailyExport` all use raw `fetch()` with no 401-refresh-then-retry logic. When the 15-minute access token expires mid-session, all three functions throw immediately (`Error("Export failed: 401")`) with no session recovery. The auth store is not cleared, so the user cannot distinguish a token expiry from a network error. The `request()` helper (lines 27-30) and `fetchDocumentContent()` (lines 520-529) both implement this retry correctly.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
// Simplest fix for adminListDailyExports — route through request() which handles 401 retry:
|
||||
// adminListDailyExports — route through request() which has retry built in:
|
||||
export function adminListDailyExports() {
|
||||
return request('/api/admin/audit-log/daily-exports')
|
||||
}
|
||||
|
||||
// For CSV-returning functions, add the same retry block as fetchDocumentContent:
|
||||
// In adminExportAuditLogCsv and adminDownloadDailyExport, replace:
|
||||
// if (!res.ok) throw new Error(...)
|
||||
// with:
|
||||
if (res.status === 401) {
|
||||
try {
|
||||
await authStore.refresh()
|
||||
return adminExportAuditLogCsv(params) // retry
|
||||
} catch {
|
||||
authStore.accessToken = null
|
||||
authStore.user = null
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
// adminExportAuditLogCsv and adminDownloadDailyExport — add after the fetch() call:
|
||||
if (res.status === 401 && !options?._retry) {
|
||||
try {
|
||||
await authStore.refresh()
|
||||
return adminExportAuditLogCsv(params) // retry once
|
||||
} catch {
|
||||
authStore.accessToken = null
|
||||
authStore.user = null
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
if (!res.ok) throw new Error(`Export failed: ${res.status}`)
|
||||
}
|
||||
```
|
||||
|
||||
### CR-05: UUID format mismatch in quota SQL — quota enforcement unreliable in `confirm_upload`
|
||||
|
||||
**File:** `backend/api/documents.py:348-356`
|
||||
|
||||
**Issue:** The atomic quota `UPDATE` in `confirm_upload` strips dashes from the user UUID:
|
||||
```python
|
||||
{"delta": size, "uid": str(doc.user_id).replace("-", "")}
|
||||
```
|
||||
PostgreSQL stores UUIDs in native `uuid` type (dashed format). Binding a 32-hex-char undashed string against a `uuid`-typed column via `text()` produces inconsistent type coercion behavior across psycopg driver versions. In contrast, `services/storage.py:178` passes the UUID with dashes (no `.replace("-", "")`). If the quota row is not found by the UPDATE, `row` is `None` and every confirm call returns HTTP 413 (quota exceeded) even when the user has available quota — making all uploads fail.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
# Line 348 — remove .replace("-", ""):
|
||||
{"delta": size, "uid": str(doc.user_id)}
|
||||
|
||||
# Line 356 — same fix:
|
||||
{"uid": str(doc.user_id)}
|
||||
```
|
||||
|
||||
### CR-06: `Content-Disposition` filename not RFC 5987-encoded — header injection via special characters
|
||||
|
||||
**File:** `backend/api/documents.py:791`
|
||||
|
||||
**Issue:**
|
||||
```python
|
||||
"content-disposition": f'inline; filename="{doc.filename}"',
|
||||
```
|
||||
`doc.filename` is user-supplied and stored verbatim. A filename containing `"` or `\r\n` can inject additional HTTP header fields. The filename validator at line 86-89 only blocks `/` and `\` — it does not block quotes or CRLF sequences. RFC 5987 encoding is required for non-ASCII and special-character filenames.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
import urllib.parse
|
||||
safe_name = urllib.parse.quote(doc.filename, safe='')
|
||||
headers = {
|
||||
...
|
||||
"content-disposition": f"inline; filename*=UTF-8''{safe_name}",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### CR-07: `PATCH /api/shares/{share_id}` writes no audit log — permission escalations unrecorded
|
||||
|
||||
**File:** `backend/api/shares.py:246-270`
|
||||
|
||||
**Issue:** `update_share_permission` changes the effective access level on a document share (e.g. `"view"` → `"edit"`) but writes no audit log entry. Every other share mutation — `grant_share` (logs `share.granted`) and `revoke_share` (logs `share.revoked`) — writes to the audit log. A permission escalation on a high-value document is therefore invisible in the ADMIN-06 audit trail. The endpoint also has no `Request` parameter, so IP address cannot be captured.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
@router.patch("/{share_id}", status_code=200)
|
||||
async def update_share_permission(
|
||||
share_id: str,
|
||||
body: SharePermissionPatch,
|
||||
request: Request, # add
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> dict:
|
||||
...
|
||||
share.permission = body.permission
|
||||
|
||||
await write_audit_log(
|
||||
session=session,
|
||||
event_type="share.permission_changed",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=share.document_id,
|
||||
ip_address=_ip(request),
|
||||
metadata_={"share_id": str(share.id), "new_permission": body.permission},
|
||||
)
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Temporary password generation has a fixed suffix that leaks 3 bytes of entropy
|
||||
### WR-01: `generateRandomPassword` discards 4 random chars and appends a fixed suffix
|
||||
|
||||
**File:** `frontend/src/components/admin/AdminUsersTab.vue:301`
|
||||
**File:** `frontend/src/components/admin/AdminUsersTab.vue:299-302`
|
||||
|
||||
**Issue:** `generateRandomPassword()` generates 16 bytes of random data mapped through
|
||||
a 65-character charset, then unconditionally overwrites the last 4 characters with the
|
||||
literal `'A1!'`:
|
||||
```js
|
||||
**Issue:**
|
||||
```javascript
|
||||
pw = pw.slice(0, 12) + 'A1!'
|
||||
```
|
||||
The generated password is always exactly 15 characters with the last 3 bytes fixed
|
||||
(`A`, `1`, `!`). An attacker who reverse-engineers this algorithm can reduce the
|
||||
brute-force search space significantly — the last 3 positions are always known. There is
|
||||
also modulo bias: `256 % 65 = 61`, meaning the first 61 characters of the charset are
|
||||
slightly overrepresented (each maps from 4 byte values instead of 3). While these
|
||||
temporary passwords must be changed on first login, they are the sole credential
|
||||
protecting accounts between creation and first use.
|
||||
The 16-char random password is truncated to 12, then `'A1!'` is always appended. The last 3 characters carry zero entropy. A brute-force attacker who knows the generation algorithm needs to search only 12 random positions, not 15. These passwords protect accounts between admin creation and first login.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
function generateRandomPassword() {
|
||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
const lower = 'abcdefghijkmnpqrstuvwxyz'
|
||||
const digits = '23456789'
|
||||
const special = '!@#$%^&*'
|
||||
const all = upper + lower + digits + special // 58 chars — 256 % 58 = 24, bias reduced
|
||||
const arr = new Uint32Array(16)
|
||||
crypto.getRandomValues(arr)
|
||||
let chars = Array.from(arr.slice(0, 12), v => all[v % all.length])
|
||||
// Guarantee one char from each required class by replacing 4 specific positions
|
||||
const positions = new Uint32Array(4)
|
||||
crypto.getRandomValues(positions)
|
||||
chars[positions[0] % 12] = upper[positions[0] % upper.length]
|
||||
chars[positions[1] % 12] = lower[positions[1] % lower.length]
|
||||
chars[positions[2] % 12] = digits[positions[2] % digits.length]
|
||||
chars[positions[3] % 12] = special[positions[3] % special.length]
|
||||
return chars.join('')
|
||||
}
|
||||
```
|
||||
**Fix:** Replace the fixed-suffix approach with positional injection of required character classes within the random portion, keeping all positions random. See also note: the charset length is 64, so `256 % 64 == 0` — no modulo bias — but the truncation from 16 to 12 chars before appending is still an entropy loss.
|
||||
|
||||
---
|
||||
|
||||
### WR-02: `format` query parameter is accepted but ignored — always returns CSV regardless
|
||||
### WR-02: `format` query parameter on `/audit-log/export` is accepted but ignored — dead parameter
|
||||
|
||||
**File:** `backend/api/audit.py:313`
|
||||
|
||||
**Issue:** The `export_audit_log` endpoint declares `format: str = Query(default="csv")`
|
||||
but the variable `format` is never read in the handler body. Passing `?format=json`
|
||||
silently returns a CSV response. The parameter is either dead code or a future extension
|
||||
stub. Either way the current behaviour is incorrect: the API contract implies multiple
|
||||
formats are accepted when only CSV exists. Any string passes FastAPI's validation,
|
||||
meaning callers receive misleading 200 OK responses for unsupported format values.
|
||||
**Issue:** `format: str = Query(default="csv")` is declared but the variable `format` is never read in the handler. Any caller passing `?format=json` receives a CSV response with HTTP 200 and no error. This is misleading API design — the parameter should either be used or removed.
|
||||
|
||||
**Fix (if only CSV will ever be supported):** Remove the parameter entirely.
|
||||
|
||||
**Fix (if future extension is planned):** Validate at the handler level:
|
||||
**Fix (simplest):** Remove the parameter. If JSON export is planned for later, add a `Literal["csv"]` constraint:
|
||||
```python
|
||||
from typing import Literal
|
||||
format: Literal["csv"] = Query(default="csv"), # noqa: A002
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-03: Pagination "Next" button uses wrong heuristic — breaks on last page when total is exact multiple of page size
|
||||
### WR-03: Pagination "Next" button uses wrong heuristic — breaks when total is exact multiple of page size
|
||||
|
||||
**File:** `frontend/src/components/admin/AuditLogTab.vue:137,266`
|
||||
|
||||
**Issue:** The "Next" button is disabled when `entries.value.length < perPage` (line 137
|
||||
in template, line 266 in script). If the last page contains exactly `perPage` (50)
|
||||
entries, the button stays enabled. Clicking it dispatches a page request that returns an
|
||||
empty items list, leaving the user on a blank page with the page counter incremented. The
|
||||
`total` ref is populated from `data.total` (line 227) but is never used for pagination.
|
||||
**Issue:** The "Next" button is disabled when `entries.value.length < perPage`. If the last page has exactly `perPage` entries, the button remains enabled. Clicking it fetches an empty page and leaves the user on a blank audit log view with the page counter incremented. The `total` ref is populated from `data.total` but is never used for pagination control.
|
||||
|
||||
**Fix:**
|
||||
```html
|
||||
<!-- Template, line 137: -->
|
||||
<!-- Template line 137: -->
|
||||
:disabled="page * perPage >= total"
|
||||
```
|
||||
```js
|
||||
// Script, nextPage():
|
||||
```javascript
|
||||
function nextPage() {
|
||||
if (page.value * perPage < total.value) {
|
||||
page.value++
|
||||
@@ -278,77 +264,72 @@ function nextPage() {
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-04: `loadDailyExports` swallows errors silently — admin sees "No daily exports" instead of an error
|
||||
### WR-04: `loadDailyExports` swallows errors silently — admin sees "no exports" instead of error message
|
||||
|
||||
**File:** `frontend/src/components/admin/AuditLogTab.vue:289-299`
|
||||
|
||||
**Issue:** The `loadDailyExports` catch block sets `dailyExports.value = []` but does
|
||||
not set `exportsError.value`. The template renders `<p v-if="exportsError">` for
|
||||
download errors, but load failures produce no user-visible message. An admin whose MinIO
|
||||
is misconfigured sees "No daily exports available" — identical to a legitimately empty
|
||||
bucket — with no indication that an error occurred.
|
||||
**Issue:** The catch block sets `dailyExports.value = []` but never sets `exportsError.value`. An admin whose MinIO bucket is unreachable sees "No daily exports available" — identical to a legitimately empty bucket — with no error indication.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
} catch (e) {
|
||||
dailyExports.value = []
|
||||
exportsError.value = 'Failed to load daily exports. Please try again.' // add
|
||||
}
|
||||
```javascript
|
||||
} catch (e) {
|
||||
dailyExports.value = []
|
||||
exportsError.value = 'Failed to load daily exports. Please try again.'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### WR-05: `URL.revokeObjectURL` called synchronously before browser download begins — potential silent cancellation
|
||||
|
||||
### WR-05: `URL.revokeObjectURL` called synchronously before browser initiates download — potential silent cancellation
|
||||
**File:** `frontend/src/api/client.js:425-426,481-482`
|
||||
|
||||
**File:** `frontend/src/api/client.js:425-426, 481-482`
|
||||
|
||||
**Issue:** In both `adminExportAuditLogCsv` and `adminDownloadDailyExport`:
|
||||
```js
|
||||
a.click()
|
||||
URL.revokeObjectURL(url) // immediate — race condition
|
||||
```
|
||||
`a.click()` triggers the download asynchronously. Revoking the blob URL before the
|
||||
browser has transferred it to the OS download manager can silently cancel the download
|
||||
on slow or memory-constrained devices (Chrome has a documented race here; this is a
|
||||
known pattern issue). The anchor element is also not appended to the DOM (`document.body.appendChild(a)`)
|
||||
which causes the download to fail entirely in some Firefox versions.
|
||||
**Issue:** Both CSV download functions call `a.click()` then immediately `URL.revokeObjectURL(url)`. The `click()` is asynchronous relative to the OS download manager handoff; revoking before the handoff is complete can silently cancel the download on some browser/OS combinations. The `<a>` element is also never appended to the DOM, which causes silent failure in Firefox.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
```javascript
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-06: `listShares` uses raw string interpolation in query string — inconsistent and fragile
|
||||
### WR-06: `listShares` uses raw template string for query params — inconsistent with all other API functions
|
||||
|
||||
**File:** `frontend/src/api/client.js:353`
|
||||
|
||||
**Issue:**
|
||||
```js
|
||||
export function listShares(docId) {
|
||||
return request(`/api/shares?document_id=${docId}`)
|
||||
}
|
||||
```javascript
|
||||
return request(`/api/shares?document_id=${docId}`)
|
||||
```
|
||||
`docId` is always a UUID in normal use, but it is not encoded via `URLSearchParams`.
|
||||
All other query-parameter functions in this file (`listDocuments`, `adminListAuditLog`,
|
||||
`listFolders`) use `URLSearchParams` consistently. While the practical risk from a UUID
|
||||
is low, the pattern is inconsistent and breaks if the value ever contains special
|
||||
characters.
|
||||
All other functions in this file use `URLSearchParams`. While `docId` is always a UUID in practice (low injection risk), this is inconsistent and fragile. The pattern would break if `docId` ever contained `+`, `&`, or `=`.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
```javascript
|
||||
export function listShares(docId) {
|
||||
const params = new URLSearchParams({ document_id: docId })
|
||||
return request(`/api/shares?${params}`)
|
||||
}
|
||||
```
|
||||
|
||||
### WR-07: `X-Forwarded-For` used as trusted client IP without validation — IP spoofing in audit logs
|
||||
|
||||
**File:** `backend/api/admin.py:249,301,411,456,517`, `backend/api/documents.py:379,635`, `backend/api/shares.py:67`
|
||||
|
||||
**Issue:** All audit log IP captures use:
|
||||
```python
|
||||
request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
```
|
||||
`X-Forwarded-For` is a client-controlled header. Any actor can forge it: `X-Forwarded-For: 127.0.0.1`. This allows an attacker to record any IP address in the audit log for their actions, defeating one of the audit trail's primary forensic values.
|
||||
|
||||
**Fix:** Deploy a reverse proxy that overwrites `X-Forwarded-For` with the real remote IP before it reaches FastAPI (e.g. nginx `proxy_set_header X-Forwarded-For $remote_addr;`), or use a trusted-proxy middleware that only reads the header when the request originates from a known proxy CIDR. Document this deployment requirement prominently.
|
||||
|
||||
### WR-08: `storage.delete_document` commits inside the service, then `delete_document` API handler commits again — split-transaction audit log risk
|
||||
|
||||
**File:** `backend/api/documents.py:654-668` and `backend/services/storage.py:182`
|
||||
|
||||
**Issue:** `storage.delete_document` calls `await session.commit()` at line 182, which ends the transaction. The API handler then calls `write_audit_log` and `await session.commit()` at lines 659-668, which commits in a *separate* transaction. If any statement between the two commits raises an exception, the document row is gone but the audit log entry is never written — a silent gap in the audit trail. This is a transaction atomicity violation.
|
||||
|
||||
**Fix:** Move the audit log write into `storage.delete_document`, or refactor `storage.delete_document` to not commit internally (let the caller control commit boundaries, passing `auto_commit=False`).
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
@@ -357,68 +338,60 @@ export function listShares(docId) {
|
||||
|
||||
**File:** `backend/api/audit.py:97-121`
|
||||
|
||||
**Issue:** `_build_filtered_query` is documented as providing the COUNT query helper
|
||||
(to avoid multi-column JOIN ambiguity), but `list_audit_log` (lines 276-284) manually
|
||||
re-implements identical filter logic inline without calling this helper. The function
|
||||
is never referenced anywhere in the file. It is dead code that will confuse maintainers.
|
||||
**Issue:** `_build_filtered_query` is documented as the COUNT-query helper to avoid JOIN ambiguity, but `list_audit_log` manually re-implements the same filter logic inline (lines 276-284) without calling this function. It is never referenced anywhere in the file.
|
||||
|
||||
**Fix:** Either delete `_build_filtered_query` or refactor the inline count query in
|
||||
`list_audit_log` to use it.
|
||||
**Fix:** Delete `_build_filtered_query`, or refactor the inline count query in `list_audit_log` to use it.
|
||||
|
||||
---
|
||||
### IN-02: `UserCreate.role` accepts arbitrary strings — no allowlist validation
|
||||
|
||||
### IN-02: Unused `import re` in `test_documents.py`
|
||||
**File:** `backend/api/admin.py:101`
|
||||
|
||||
**File:** `backend/tests/test_documents.py:9`
|
||||
|
||||
**Issue:** `import re` is present at line 9 but `re.` is never used anywhere in the
|
||||
module. Dead import from a previous version of the test file.
|
||||
|
||||
**Fix:** Remove `import re` from line 9.
|
||||
|
||||
---
|
||||
|
||||
### IN-03: `deleteDocumentRemoveOnly` is a trivial one-liner wrapper — unnecessary exported symbol
|
||||
|
||||
**File:** `frontend/src/api/client.js:80-82`
|
||||
|
||||
**Issue:**
|
||||
```js
|
||||
export function deleteDocumentRemoveOnly(id) {
|
||||
return deleteDocument(id, true)
|
||||
}
|
||||
```
|
||||
This adds a second exported name for `deleteDocument(id, true)` with no additional
|
||||
behaviour. Having two symbols for the same operation creates confusion about which to
|
||||
use. The wrapper itself provides no documentation, type safety, or default-arg isolation.
|
||||
|
||||
**Fix:** Remove the wrapper and call `deleteDocument(id, true)` directly at callsites,
|
||||
or keep only the wrapper and make `removeOnly` an implementation detail.
|
||||
|
||||
---
|
||||
|
||||
### IN-04: `test_delete_cloud_remove_only` does not assert quota is unchanged after DB-only removal
|
||||
|
||||
**File:** `backend/tests/test_documents.py:897-925`
|
||||
|
||||
**Issue:** The test verifies the DB row is removed but does not verify that the user's
|
||||
`used_bytes` quota was not decremented. Cloud documents are not quota-tracked, so a
|
||||
future regression that incorrectly decrements quota on `remove_only` paths would go
|
||||
undetected by this test.
|
||||
**Issue:** `role: str = "user"` accepts any string. An admin can inadvertently create a user with `role="superuser"` or any future privileged role string. If a new role is added later, the API silently accepts it before guards are updated.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
# Add after the deleted-row assertion:
|
||||
from db.models import Quota
|
||||
quota = await db_session.get(Quota, auth_user["user"].id)
|
||||
# Cloud docs are not quota-tracked — used_bytes must remain 0
|
||||
assert quota.used_bytes == 0, (
|
||||
f"remove_only should not decrement quota, got used_bytes={quota.used_bytes}"
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
handle: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
role: Literal["user", "admin"] = "user"
|
||||
```
|
||||
|
||||
### IN-03: `import re` unused in `test_documents.py`
|
||||
|
||||
**File:** `backend/tests/test_documents.py:9`
|
||||
|
||||
**Issue:** `import re` is present but `re` is never used in the test file.
|
||||
|
||||
**Fix:** Remove the import.
|
||||
|
||||
### IN-04: `initiate_password_reset` writes no audit log
|
||||
|
||||
**File:** `backend/api/admin.py:330-359`
|
||||
|
||||
**Issue:** All other admin operations log an audit entry. `initiate_password_reset` does not record which admin triggered a reset for which user, making it impossible to investigate suspicious reset activity post-incident. This is an ADMIN-03 gap.
|
||||
|
||||
**Fix:** Add `write_audit_log` with `event_type="admin.password_reset_initiated"`, `user_id=user.id`, `actor_id=_admin.id`. This requires also adding `request: Request` as a parameter.
|
||||
|
||||
### IN-05: `test_delete_cloud_remove_only` does not assert quota is unchanged
|
||||
|
||||
**File:** `backend/tests/test_documents.py:897-925`
|
||||
|
||||
**Issue:** The test verifies the DB row is deleted but does not verify that `used_bytes` was not decremented. Cloud documents are not quota-tracked; a future regression that incorrectly decrements quota on the `remove_only` path would go undetected.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
from db.models import Quota
|
||||
quota = await db_session.get(Quota, auth_user["user"].id)
|
||||
assert quota.used_bytes == 0, (
|
||||
f"remove_only must not decrement quota, got used_bytes={quota.used_bytes}"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-05-31T00:00:00Z_
|
||||
_Reviewed: 2026-05-31T12:00:00Z_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
|
||||
Reference in New Issue
Block a user