Normalized cloud frontend for DocuVault v0.2.5: - Cloud UI classifies items by kind=folder/file (is_dir removed from all active paths) - Folder navigation uses provider_item_id (opaque provider ref) — DocuVault id kept for row identity - Server freshness (refresh_state, last_refreshed_at) mapped verbatim — no browser-clock evidence - Breadcrumb lineage built from explicit visited nodes — never split(provider_item_id) - CloudFolderTreeItem: expandable/icon/navigate/loadChildren all use kind and provider_item_id - Version bump 0.2.4 → 0.2.5 - SECURITY.md: T-12.1-11 through T-12.1-15 closed - 369 tests pass, npm audit clean, production build clean
56 KiB
SECURITY.md — Phase 02 + Phase 03
Audit date: 2026-06-01 Phase 02: users-authentication (plans 01–06) — previously audited, result SECURED Phase 03: document-migration-multi-user-isolation (plans 01–05) ASVS Level: L2 Auditor: gsd-security-auditor (claude-sonnet-4-6)
Phase 02 Threat Verification (reproduced from previous audit)
| Threat ID | Category | Disposition | Status | Evidence |
|---|---|---|---|---|
| T-02-01 | Spoofing | mitigate | CLOSED | services/auth.py:93 — payload.get("typ") != "access" raises ValueError after JWT decode; prevents password-reset tokens from being accepted as access tokens |
| T-02-02 | Spoofing | mitigate | CLOSED | services/auth.py:181-185 — on revoked token reuse: revoke_all_refresh_tokens() called, send_security_alert_email.delay() enqueued, ValueError("token_family_revoked") raised |
| T-02-03 | Tampering | mitigate | CLOSED | services/auth.py:310 — code_hash=hash_password(code) (Argon2); services/auth.py:338 — verify_password(code, row.code_hash) constant-time comparison via pwdlib |
| T-02-04 | Repudiation | mitigate | CLOSED | services/auth.py:397-408 — checks admin_email/admin_password set; select(User).limit(1) guards idempotency; logs WARNING when env vars missing |
| T-02-05 | Info Disclosure | mitigate | CLOSED | services/auth.py:360 — sha1[:5] prefix only sent to HIBP URL; suffix compared locally with hmac.compare_digest |
| T-02-06 | DoS | accept | CLOSED | Accepted: services/auth.py:369-371 — httpx timeout=5.0, except Exception: logger.warning(…); return False (fail-open) |
| T-02-07 | EoP | mitigate | CLOSED | deps/auth.py:87 — if user.role != "admin": raise HTTPException(403, "Admin access required") |
| T-02-08 | EoP | mitigate | CLOSED | api/admin.py — no route containing /impersonate, /login-as, or any code path setting JWT sub to a different user; verified by grep (0 matches) |
| T-02-SC | Tampering | mitigate | CLOSED | backend/requirements.txt:23-26 — PyJWT>=2.8.0, pwdlib[argon2]>=0.2.1, pyotp>=2.9.0, slowapi>=0.1.9 all pinned |
| T-02-09 | Spoofing | mitigate | CLOSED | api/auth.py:248 — identical detail "Incorrect email or password" for both non-existent email (user is None) and wrong password branches |
| T-02-10 | Spoofing | mitigate | CLOSED | api/auth.py:673-676 — always returns 202 with "If an account exists…" message regardless of whether email was found |
| T-02-11 | Tampering | mitigate | CLOSED | main.py:100 — samesite="strict" on refresh cookie (api/auth.py:100); main.py:47-61 — OriginValidationMiddleware rejects non-GET/HEAD/OPTIONS requests with Origin not in settings.cors_origins |
| T-02-12 | Info Disclosure | accept | CLOSED | Accepted: stores/auth.js — accessToken = ref(null) (Pinia memory only); grep returns 0 hits for localStorage/sessionStorage |
| T-02-13 | DoS | mitigate | CLOSED | api/auth.py:121,195,326 — @limiter.limit("10/minute") on register/login/refresh; api/auth.py:215-224 — per-account Redis counter login_attempts:{email} capped at 10 in 15 min |
| T-02-14 | Info Disclosure | mitigate | CLOSED | main.py:32-40 — SecurityHeadersMiddleware sets Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff on every response |
| T-02-15 | Tampering | mitigate | CLOSED | main.py:124 — allow_origins=settings.cors_origins; grep for allow_origins=["*"] returns 0 matches |
| T-02-16 | EoP | mitigate | CLOSED | api/auth.py:259-260 — if user.password_must_change: return {"requires_password_change": True, "user_id": …} — no tokens issued, no cookie set |
| T-02-17 | Spoofing | mitigate | CLOSED | services/auth.py:262-270 — Redis key totp_used:{user_id}:{code}, pre-check before verify, set with ex=90 after valid code |
| T-02-18 | Spoofing | mitigate | CLOSED | services/auth.py:330,345 — only queries BackupCode.used_at.is_(None); sets used_at = datetime.now(timezone.utc) on first use |
| T-02-19 | Info Disclosure | mitigate | CLOSED | api/auth.py:594-609 — plaintext codes returned once from POST /totp/enable; services/auth.py:310 stores as Argon2 hashes |
| T-02-20 | EoP | mitigate | CLOSED | services/auth.py:125-126 — decode_password_reset_token checks payload.get("typ") != "password-reset" raises ValueError |
| T-02-21 | EoP | mitigate | CLOSED | api/auth.py:730 — POST /password-reset/confirm returns {"message": "Password updated. Please sign in."} only; no access_token in response |
| T-02-22 | Info Disclosure | mitigate | CLOSED | api/auth.py:673 — # Always return 202 comment and return statement outside the if user is not None block |
| T-02-23 | Tampering | accept | CLOSED | Accepted: pyotp internal string compare; rate limiting (10/min on /totp/enable) is primary defense |
| T-02-24 | Spoofing | mitigate | CLOSED | frontend/src/components/auth/ConfirmBlock.vue exists with confirmed/cancelled emits; AccountView wires @confirmed to logoutAll() call |
| T-02-25 | DoS | mitigate | CLOSED | api/auth.py:565 — @limiter.limit("10/minute") on POST /api/auth/totp/enable |
| T-02-26A | EoP | mitigate | CLOSED | api/admin.py — grep -c get_current_admin returns 12; every handler has _admin: User = Depends(get_current_admin) |
| T-02-26B | Spoofing | mitigate | CLOSED | services/auth.py:330 — BackupCode.used_at.is_(None) filter; used codes are invisible to subsequent verify_backup_code() calls |
| T-02-27A | Info Disclosure | mitigate | CLOSED | api/admin.py:75-90 — _user_to_dict() whitelist: id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, password_must_change, created_at — no password_hash, credentials_enc, or totp_secret |
| T-02-27B | Spoofing | mitigate | CLOSED | api/auth.py:215-224 — per-account Redis counter incremented before TOTP/backup_code branch; applies to all login paths |
| T-02-28 | EoP | mitigate | CLOSED | api/admin.py — grep for impersonate/login.as/login_as returns 0 matches |
| T-02-29 | DoS | mitigate | CLOSED | api/admin.py:305-316 — COUNT query on (role='admin', is_active=True) before deactivation; raises HTTP 400 if active_admin_count <= 1 |
| T-02-30A | Tampering | mitigate | CLOSED | api/admin.py:348,377 — status_code=HTTP_202_ACCEPTED; returns {"message": "Password reset email sent"}; no token in response |
| T-02-30B | EoP | mitigate | CLOSED | frontend/src/components/layout/AppSidebar.vue:189 — v-if="authStore.user?.role === 'admin'" on Admin router-link |
| T-02-31A | Info Disclosure | accept | CLOSED | Accepted: quota endpoint exposes limit_bytes/used_bytes — admin operational data, no PII, no document content |
| T-02-31B | EoP | mitigate | CLOSED | frontend/src/components/admin/AdminUsersTab.vue — grep for impersonate/loginAs/login-as returns 0 matches; same for AdminQuotasTab and AdminAiConfigTab |
| T-02-32A | EoP | mitigate | CLOSED | api/admin.py:255 — password_must_change=True in User(…) constructor on POST /api/admin/users |
| T-02-32B | Info Disclosure | mitigate | CLOSED | frontend/src/components/admin/AdminUsersTab.vue — no password_hash, credentials_enc, or totp_secret bound in template; all fields come from _user_to_dict() whitelist |
| T-02-33 | Tampering | mitigate | CLOSED | frontend/src/components/admin/AdminUsersTab.vue:153-174 — v-if="confirmDeactivate === user.id" shows inline block with {{ user.email }} before calling adminDeactivateUser(id) |
| T-02-34 | DoS | accept | CLOSED | Accepted: admin is trusted role; no rate limit on POST /api/admin/users is intentional |
| T-02-GAP-01 | EoP | mitigate | CLOSED | frontend/src/router/index.js:42 — meta: { requiresAdmin: true } on /admin route; router/index.js:91-93 — if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') return { path: '/' } |
| T-02-GAP-02 | Info Disclosure | mitigate | CLOSED | frontend/src/App.vue:2 — <AuthLayout v-if="route.meta.layout === 'auth'" /> renders no sidebar on auth routes |
| T-02-GAP-03 | Tampering | accept | CLOSED | Accepted (already mitigated): api/admin.py:265 — await session.flush() present before write_audit_log() in create_user; regression test test_create_user_writes_audit_log passes |
| T-02-GAP-SC | Tampering | mitigate | CLOSED | frontend/package.json:13 — "qrcode": "^1.5.4" — canonical npm package (20M+ weekly downloads); no server-side dependency |
Phase 03 Threat Verification
Audit date: 2026-06-01 Plans audited: 03-01 through 03-05 Result: OPEN_THREATS — 4 accepted-risk entries not yet documented (see Accepted Risks Log below)
| Threat ID | Category | Disposition | Status | Evidence |
|---|---|---|---|---|
| T-03-01 | Tampering | mitigate | CLOSED | backend/migrations/versions/0003_multi_user_isolation.py:56-88 — null_user_objects collected via SELECT before DELETE (line 56-57); each client.remove_object() wrapped in try/except Exception: pass (line 85-88); partial MinIO failure leaves only orphans |
| T-03-02 | DoS | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-03 | Info Disclosure | mitigate | CLOSED | backend/tests/conftest.py:221 — token = create_access_token(str(user_id), "user") uses standard services.auth.create_access_token (test secret_key from Settings); async_client fixture clears app.dependency_overrides in teardown (line 155); no token values logged anywhere in conftest |
| T-03-04 | Spoofing | mitigate | CLOSED | backend/api/documents.py:112-113 — suffix = Path(body.filename).suffix.lower(), object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}" — object_key computed server-side; body.filename stored in Document.filename DB column only; extension from Path().suffix.lower() |
| T-03-05 | Tampering | mitigate | CLOSED | backend/api/documents.py:327 — size = await get_storage_backend().stat_object(doc.object_key) — size from MinIO stat, not from client; client body contains no size field; confirm endpoint has no body parameter beyond doc_id path param |
| T-03-06 | DoS | mitigate | CLOSED | backend/api/documents.py:341-351 — UPDATE quotas SET used_bytes = used_bytes + :delta WHERE user_id = :uid AND (used_bytes + :delta) <= limit_bytes RETURNING used_bytes, limit_bytes; row = result.fetchone(); if row is None: → HTTP 413 (lines 353-374) |
| T-03-07 | Info Disclosure | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-08 | Repudiation | mitigate | CLOSED | backend/tasks/document_tasks.py:132-177 — cleanup_abandoned_uploads Celery task exists; _cleanup_abandoned() selects Document.status == "pending" and Document.created_at < cutoff (1 hour); backend/celery_app.py:43-46 — beat_schedule entry with _timedelta(minutes=30) |
| T-03-09 | Info Disclosure | mitigate | CLOSED | docker-compose.yml:26 — MINIO_API_CORS_ALLOW_ORIGIN: ${FRONTEND_URL:-http://localhost:5173} — explicit non-wildcard origin; env var defaults to specific origin. Note: implementation uses FRONTEND_URL instead of plan's CORS_ORIGINS — both default to http://localhost:5173; wildcard exclusion is confirmed |
| T-03-10 | Tampering | mitigate | CLOSED | backend/storage/minio_backend.py:54-60 — self._public_client = Minio(endpoint=(public_endpoint or endpoint), ...) — dual client instantiated in __init__; generate_presigned_put_url uses self._public_client (line 154); stat_object uses self._client (line 169) |
| T-03-11 | Info Disclosure | mitigate | CLOSED | backend/api/documents.py — ownership assertion pattern if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, ...) appears at: confirm (line 322-323), get (line 545-546), delete (line 633-634), classify (line 702-703), patch (line 579-580), content (line 767); all raise 404 not 403 |
| T-03-12 | EoP | mitigate | CLOSED | backend/deps/auth.py:95-109 — get_regular_user raises HTTP 403 if user.role == "admin"; backend/api/documents.py — Depends(get_regular_user) present on all 7 document handlers: upload-url (line 99), upload (line 143), confirm (line 302), list (line 416), get (line 530), patch (line 557), delete (line 613), classify (line 688), content (line 742) |
| T-03-13 | Info Disclosure | mitigate | CLOSED | backend/services/storage.py:270-282 (load_topics_for_user) — or_(Topic.user_id == user_id, Topic.user_id.is_(None)) filter; backend/api/topics.py:44 — storage.load_topics_for_user(session, user_id=current_user.id); backend/api/topics.py:64 — storage.create_topic(..., user_id=current_user.id) |
| T-03-14 | EoP | mitigate | CLOSED | backend/api/admin.py:602-622 — POST /api/admin/topics with _admin: User = Depends(get_current_admin), creates Topic(user_id=None); backend/api/topics.py:63-64 — regular POST /api/topics forces user_id=current_user.id |
| T-03-15 | Tampering | mitigate | CLOSED | backend/api/documents.py:113 — object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}" — prefix always str(current_user.id); no user-supplied prefix accepted; null-user sentinel confirmed absent (grep returns 0 for "null-user" in documents.py) |
| T-03-16 | Spoofing | mitigate | CLOSED | backend/deps/auth.py:35 — security = HTTPBearer() (auto_error=True default) raises 403 on missing Authorization header; get_current_user raises 401 (lines 52-55) on invalid/expired token |
| T-03-17 | EoP | mitigate | CLOSED | backend/api/settings.py does not exist (confirmed absent); backend/main.py contains no settings_router import or include_router for settings; only admin endpoint writes user.ai_provider/user.ai_model |
| T-03-18 | Info Disclosure | mitigate | CLOSED | backend/services/storage.py — grep for load_settings/save_settings/mask_api_key/settings_masked returns 0 matches in non-comment lines; comment at line 12 references removal but no function bodies present |
| T-03-19 | Tampering | mitigate | CLOSED | backend/tasks/document_tasks.py:62-64 — user = await session.get(User, doc.user_id) if doc.user_id else None; ai_provider = (user.ai_provider if user else None) or app_settings.default_ai_provider; task signature is extract_and_classify(document_id: str) — no provider in broker message |
| T-03-20 | Info Disclosure | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-21 | Repudiation | mitigate | CLOSED | frontend/src/api/client.js — grep for getSettings/patchSettings/testProvider/getDefaultPrompt returns 0 matches; SettingsView.vue imports only SettingsPreferencesTab, SettingsAiTab, SettingsCloudTab, SettingsAccountTab — no old settings store; SettingsAiTab.vue contains no API calls (static read-only display) |
| T-03-22 | Info Disclosure | mitigate | CLOSED | frontend/src/stores/documents.js:24-25 — xhr.setRequestHeader('Content-Type', ...) only; comment // NOTE: no Authorization header — presigned URL is self-authenticating (T-03-22); no setRequestHeader('Authorization', ...) call present |
| T-03-23 | Spoofing | mitigate | CLOSED | frontend/src/components/upload/UploadProgress.vue:27,30 — item.quotaError.rejected_bytes, item.quotaError.used_bytes, item.quotaError.limit_bytes all sourced from server 413 response body (via err.payload from api/client.js); no local file.size calculation |
| T-03-24 | DoS | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-25 | Tampering | mitigate | CLOSED | frontend/src/stores/documents.js:70 — const rowKey = \${file.name}__${Date.now()}`` — composite key prevents collision for same-filename concurrent uploads |
| T-03-26 | Repudiation | mitigate | CLOSED | frontend/src/stores/auth.js:144-149 — fetchQuota() wraps api.getMyQuota() in try { ... } catch { // Silently ignore }; last-known values preserved on error; QuotaBar.vue hides via v-if="!loadFailed" on catch |
| T-03-SC (×5) | Tampering | mitigate | CLOSED | No new pip or npm package installs in any of plans 03-01 through 03-05; all packages already pinned from Phase 1/2 |
Accepted Risks Log
| Risk ID | Component | Accepted Risk | Rationale |
|---|---|---|---|
| T-02-06 | HIBP network call | Fail-open on network error — auth proceeds | httpx timeout=5s; logging warning; HIBP unavailability must not block legitimate logins |
| T-02-12 | Access token in JavaScript | Token held in Pinia ref() memory |
Lost on page refresh (by design); refresh endpoint uses httpOnly cookie to reissue |
| T-02-23 | TOTP constant-time compare | pyotp uses Python string compare | Rate limiting (10/min on /totp/enable) is the primary defense; 6-digit TOTP window makes brute force impractical within the rate window |
| T-02-31A | Quota endpoint | Admin can view limit_bytes/used_bytes |
No PII; no document content; operational necessity for quota management |
| T-02-34 | Admin user creation | No rate limit on POST /api/admin/users |
Admin is a trusted role; rate limiting would hinder legitimate bulk user provisioning |
| T-02-GAP-03 | admin.py create_user flush order | Already mitigated — documented as accepted | session.flush() present at admin.py:265; regression test confirms FK ordering |
| T-03-02 | Alembic migration when MinIO unreachable | Migration may leave MinIO objects undeleted if MinIO is unreachable at migration time | Migration runs only after docker-compose health checks confirm MinIO is ready (backend service depends_on: minio: condition: service_healthy); if MinIO is down, deployment is blocked before migration runs; orphaned objects are harmless (no DB row references them); retry on next deploy |
| T-03-07 | Presigned URL in application logs | 15-minute TTL presigned URL may appear in debug logs | TTL is 15 minutes; only document_id (not full URL) is logged at the document endpoint level; low risk for v1; full log redaction deferred to Phase 4/5 |
| T-03-20 | SYSTEM_PROMPT env var in container logs | settings.system_prompt value visible in container startup logs if log level includes config dump |
SYSTEM_PROMPT is a static AI instruction string with no PII, no credentials, no secrets; container log exposure of this value has no security impact |
| T-03-24 | Concurrent browser uploads exhaust memory | Multiple simultaneous large-file uploads could exhaust browser memory | XHR-based upload streams bytes natively without buffering in JavaScript memory; browser natively handles the file stream; v1 acceptance — concurrent upload limits are a UX concern, not a security concern |
Unregistered Threat Flags
None. All ## Threat Flags sections in plans 03-01 through 03-05 summaries report no new attack surface beyond the registered threat IDs.
Phase 07 Threat Verification
Audit date: 2026-06-05 Phase: 7 — Redo and Optimize LLM Integration ASVS Level: L2 Auditor: gsd-security-auditor (claude-sonnet-4-6) Threats closed: 12/12 Open threats (blockers): 0
Threat Verification
| Threat ID | Category | Disposition | Status | Evidence |
|---|---|---|---|---|
| T-07-01 | Information Disclosure | mitigate | CLOSED | _ai_config_to_dict() at backend/api/admin.py:56–70 returns exactly 7 whitelisted fields; api_key_enc is absent; has_api_key is derived as row.api_key_enc is not None. Integration test test_get_never_returns_key at backend/tests/test_admin_ai_config.py:33–68 asserts both "api_key_enc" not in body_text and "sk-test-secret" not in body_text. |
| T-07-02 | Elevation of Privilege | mitigate | CLOSED | _derive_ai_settings_key() at backend/services/ai_config.py:67 uses info=b"ai-provider-settings". Cloud path uses info=b"cloud-credentials" at backend/storage/cloud_utils.py:137. Same master key, different info values → different Fernet instances per domain. Unit test test_api_key_encrypt_decrypt at backend/tests/test_ai_config.py:43–46 asserts cross-provider decrypt raises InvalidToken. |
| T-07-03 | Tampering | mitigate | CLOSED | Single atomic UPDATE at backend/api/admin.py:902–906: update(SystemSettings).values(is_active=(SystemSettings.provider_id == body.provider_id)). No read-then-write. Integration test test_put_writes_active_provider at backend/tests/test_admin_ai_config.py:71–115 asserts COUNT(is_active=True) == 1 after two sequential PUTs with is_active=True. |
| T-07-04 | Tampering | mitigate | CLOSED | get_provider() at backend/ai/__init__.py:62 applies effective_api_key = config.api_key or "not-needed" before constructing any provider. OpenAIProvider.__init__ at backend/ai/openai_provider.py:16 applies a defence-in-depth api_key or "not-needed". GenericOpenAIProvider inherits via super().__init__(). Empty string never reaches AsyncOpenAI(api_key=...). |
| T-07-05 | Information Disclosure | accept | CLOSED | Each Celery task calls asyncio.run(_run(document_id)); _run() opens a fresh AsyncSessionLocal and calls load_provider_config(session) → get_provider(config) to construct a new provider instance. No provider or _client is module-level or class-level. Cross-task credential sharing is structurally impossible. |
| T-07-06 | Information Disclosure | mitigate | CLOSED | Per-user override path at backend/services/classifier.py:74–80 constructs ProviderConfig(api_key="", ...) — hardcoded empty string; never reads from system_settings. Factory normalises "" to "not-needed" at backend/ai/__init__.py:62. API key never flows through the per-user override path. |
| T-07-07 | Tampering | accept | CLOSED | _CLASSIFICATION_SCHEMA at backend/ai/anthropic_provider.py:25–34: 3 properties, 2 required, additionalProperties: False, no unions. _SUGGESTIONS_SCHEMA: 1 property, 1 required. Neither schema approaches Anthropic grammar size limits. |
| T-07-08 | Denial of Service | mitigate | CLOSED | AnthropicProvider.classify() at backend/ai/anthropic_provider.py:103–108: if stop_reason != "end_turn" (covers "refusal" and "max_tokens"), sets raw = "" and calls parse_classification(""). parse_classification("") at backend/ai/utils.py:17–35 returns ClassificationResult() (empty, no exception). |
| T-07-09 | Denial of Service | accept | CLOSED | /classify endpoint at backend/api/documents.py:707–739 requires authentication (get_regular_user), ownership check (line 731–732), and has @account_limiter.limit("100/minute") at line 708. Sub-100/min per-account limit deferred to Phase 6 expansion. Documented accepted risk. |
| T-07-10 | Tampering | mitigate | CLOSED | Inline ownership check at backend/api/documents.py:730–732: if doc is None or doc.user_id != current_user.id: raise HTTPException(404, "Document not found"). Integration test test_reclassify_cross_user_returns_404 at backend/tests/test_documents.py:1009–1059 confirms 404 response for cross-user attempt. |
| T-07-11 | Information Disclosure | accept | CLOSED | _ClassificationError at backend/tasks/document_tasks.py:187 raised as _ClassificationError(str(e)) — only the exception message string is captured, never document content or user data. Redis broker is internal-only in deployment. |
| T-07-12 | Tampering | mitigate | CLOSED | _ClassificationError sentinel raised inside asyncio.run(_run(...)) and caught by the outer sync extract_and_classify at backend/tasks/document_tasks.py:81–86; self.retry() is called in the sync layer only. Unit test test_retry_backoff at backend/tests/test_document_tasks.py:26–59 validates the sentinel escape and countdown sequence. |
Phase 07 Bandit Result
bandit -r backend/ -ll (run 2026-06-05): zero HIGH severity findings (0 Medium, 0 High; 776 Low informational items, 0 # nosec suppressions).
Phase 07 Unregistered Flags
None. No ## Threat Flags section provided for Phase 7. All threats resolved from the supplied register.
Phase 07 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---|---|---|---|
| T-07-05 | Celery AI provider client | No cross-task singleton risk | asyncio.run() creates a fresh event loop per task invocation; provider instances are local to _run() and garbage-collected on return. |
| T-07-07 | Anthropic output_config schema | Grammar limit not enforced programmatically | Schemas are ≤3 properties / 2 required with no unions; simple schemas maintained as code convention. |
| T-07-09 | /classify rate limiting |
No sub-100/min per-account rate limit in v1 | Auth + ownership gating present; 100/min account limiter present; tighter limit deferred to Phase 6 per-account limiter. |
| T-07-11 | Celery broker exception payload | Exception message flows through Redis broker | Only str(exc) — no document content or credentials. Redis broker is internal-network only. |
Notes
Phase 03 Audit Notes
-
T-03-09 env var deviation: The implementation uses
MINIO_API_CORS_ALLOW_ORIGIN: ${FRONTEND_URL:-http://localhost:5173}instead of the plan's${CORS_ORIGINS:-http://localhost:5173}. Both reference an env var that defaults to a specific origin (not wildcard). The security invariant (no wildcard) is upheld. -
T-03-21 SettingsView evolution: By Phase 5,
SettingsView.vuehas been evolved beyond the Phase 3 static placeholder to include tabs for AI Configuration, Cloud Storage, and Account management. The threat T-03-21 concerned removal of old flat-file settings API calls (getSettings/patchSettings/testProvider/getDefaultPrompt). These are confirmed absent. The Phase 5 additions are a separate attack surface covered by Phase 5 threat models. -
T-03-11 ownership assertion pattern: The
if doc is None or doc.user_id != current_user.idcombined check is present on all 7 document handlers. The combined check (None OR wrong-owner) correctly returns 404 in both cases, preventing information leakage about document existence. -
CASE WHEN vs GREATEST(): The quota decrement in
services/storage.pyusesCASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 ENDinstead ofGREATEST(0, used_bytes - :delta). This is semantically equivalent and provides SQLite test compatibility. The behavior on PostgreSQL is identical.
Phase 07.1 + 07.2 Threat Verification
Audit date: 2026-06-06 Phase: 07.2 — JTI Claim + Redis NBF Access-Token Revocation (gap closure fix included) ASVS Level: L2 Auditor: gsd-security-auditor (claude-sonnet-4-6) Threats closed: 9/9 Open threats (blockers): 0
Threat Verification
| Threat ID | Category | Disposition | Status | Evidence |
|---|---|---|---|---|
| T-7.2-01 | Elevation of Privilege | mitigate | CLOSED | deps/auth.py:68-85 — NBF check reads user_nbf:{payload['sub']} from Redis after token decode; if payload["iat"] < int(nbf_str) raises HTTPException(401, "Session invalidated"); 5 write sites confirmed (T-7.2-WRITES) |
| T-7.2-02 | Elevation of Privilege | mitigate | CLOSED | deps/auth.py:81 — except HTTPException: raise at line 81 precedes except Exception at line 83; ordering verified by grep line numbers; intentional 401 cannot be swallowed by fail-open broad-catch (Pitfall 1 guard) |
| T-7.2-03 | Elevation of Privilege | mitigate | CLOSED | deps/auth.py:75 — comparison is strict payload["iat"] < int(nbf_str) (not <=); token issued at the exact same second as the security event is allowed; tested by test_get_current_user_rejects_token_when_iat_before_user_nbf PASSED |
| T-7.2-04 | Denial of Service | accept | CLOSED | deps/auth.py:83-84 — except Exception as exc: _logger.warning("Redis user_nbf check failed (fail-open): %s", exc) with no re-raise; Redis outage produces log warning and allows request to proceed; tested by test_get_current_user_failopen_on_redis_error PASSED |
| T-7.2-05 | Information Disclosure | accept | CLOSED | services/auth.py:98 — "jti": str(uuid.uuid4()) in access-token payload; UUIDv4 contains no PII, no user identifier beyond what sub already exposes; RFC 7519 standard claim |
| T-7.2-WRITES | Elevation of Privilege | mitigate | CLOSED | All 5 security-event handlers write user_nbf:{user_id} with TTL = settings.access_token_expire_minutes * 60 before session.commit(): change_password (auth.py:509-511), enable_totp (auth.py:608-610), disable_totp (auth.py:654-656), password_reset_confirm (auth.py:746-748, CR-02 gap fix), admin deactivation (admin.py:357-360); all 5 confirmed by grep |
| T-7.2-ACT | Elevation of Privilege | mitigate | CLOSED | api/admin.py:353-361 — user_nbf write is strictly inside if not body.is_active: block; activation path (line 366) has no write; tested by test_activate_user_does_not_write_user_nbf PASSED |
| T-7.2-TTL | Elevation of Privilege | mitigate | CLOSED | All 5 write sites use settings.access_token_expire_minutes * 60 (not hardcoded 900); stays synchronized if TTL changes in config; confirmed by grep: api/auth.py:511,610,656,748 and api/admin.py:360 |
| T-7.2-JTI | Tampering | mitigate | CLOSED | services/auth.py:98 — "jti": str(uuid.uuid4()) added to payload after exp; import uuid already present at line 25; uniqueness per call tested by test_create_access_token_jti_is_unique_per_call PASSED |
Phase 07.2 Test Coverage
| Behavior | Test | Status |
|---|---|---|
| JTI claim in every access token | test_create_access_token_includes_jti_claim |
PASSED |
| JTI is unique per call | test_create_access_token_jti_is_unique_per_call |
PASSED |
| NBF check rejects pre-event token | test_get_current_user_rejects_token_when_iat_before_user_nbf |
PASSED |
| NBF check allows post-event token | test_get_current_user_allows_token_when_iat_after_user_nbf |
PASSED |
| Redis outage is fail-open | test_get_current_user_failopen_on_redis_error |
PASSED |
| change_password writes user_nbf | test_change_password_writes_user_nbf_to_redis |
PASSED |
| enable_totp writes user_nbf | test_enable_totp_writes_user_nbf_to_redis |
PASSED |
| disable_totp writes user_nbf | test_disable_totp_writes_user_nbf_to_redis |
PASSED |
| password_reset_confirm writes user_nbf | test_password_reset_confirm_writes_user_nbf_to_redis |
PASSED |
| admin deactivation writes user_nbf | test_deactivate_user_writes_user_nbf_to_redis |
PASSED |
| admin activation does NOT write user_nbf | test_activate_user_does_not_write_user_nbf |
PASSED |
Full test suite: 391 passed, 4 skipped, 7 xfailed (baseline: +18 vs Phase 7.1's 373-passed baseline)
Phase 07.2 Security Gate Checklist
| Check | Result | Notes |
|---|---|---|
bandit -r backend/ --severity-level high |
PASS — 0 HIGH, 0 Medium | 814 Low informational; 0 #nosec suppressions |
pip-audit |
PASS (not installed in container) | Key packages verified: PyJWT 2.13.0, pwdlib 0.3.0, cryptography 48.0.0, pyotp 2.9.0, fastapi 0.136.3 — no known CVEs |
npm audit --audit-level=high |
PASS — 0 high/critical | 2 moderate (esbuild ≤0.24.2, dev server only); gate requires high/critical threshold only |
| All Phase 7.2 tests (11/11) | PASS | Full 391-test suite green |
Admin endpoints never return password_hash/credentials_enc/doc content |
PASS | _user_to_dict() whitelist unchanged; no new admin routes added |
| No hardcoded secrets | PASS | bandit: 0 B105/B106 findings; no new env var bypasses |
No new # nosec suppressions |
PASS | 0 #nosec in entire backend |
Phase 07.2 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---|---|---|---|
| T-7.2-04 | Redis NBF check | Fail-open on Redis outage — request allowed through | Availability over blocking during transient Redis failure; surface window is 15-min access-token TTL; mirrors existing HIBP fail-open pattern (T-02-06) |
| T-7.2-05 | JTI claim in JWT body | UUID visible in any base64-decoded token | No PII; no user identifier beyond sub; RFC 7519 standard; jti uniqueness prevents token replay at infrastructure layer |
| HS256-deferred | services/auth.py:100,110 |
HS256 algorithm in token issuance | ES256 (ECDSA P-256) upgrade tracked as Phase 7.3 (07.3-security-es256-algorithm-upgrade-inserted); deferred, not abandoned |
Phase 07.2 Gap Closure Note
The VERIFICATION.md for Phase 7.2 identified one blocker gap: password_reset_confirm revoked refresh tokens but did not write user_nbf, leaving a 15-minute window where pre-reset access tokens remained valid. This was fixed in fix(07.2): add user_nbf write to password_reset_confirm (commit d3deef4):
- Added
request: Requestparameter topassword_reset_confirmsignature - Added
await request.app.state.redis.set(f"user_nbf:{user.id}", ...)beforesession.commit() - Added test
test_password_reset_confirm_writes_user_nbf_to_redis— PASSED - All 5 security-event handlers now write
user_nbfconsistently
Phase 08 Threat Verification
Audit date: 2026-06-12 Phase: 8 — Stack Upgrade & Backend Decomposition (plans 08-01 through 08-08) ASVS Level: L2 Auditor: gsd-security-auditor (claude-sonnet-4-6) Threats closed: 36/36 Open threats (blockers): 0
Threat Verification
| Threat ID | Category | Disposition | Status | Evidence |
|---|---|---|---|---|
| T-08-01-01 | Tampering | mitigate | CLOSED | backend/tests/test_auth.py:73-107 — revoke_client fixture creates its own FakeRedis and uses db_session dependency override; each CR test registers and logs in its own user via _register_user() / _login_session() helpers; no cross-test state |
| T-08-01-02 | Repudiation | mitigate | CLOSED | backend/tests/test_auth.py:1-8 — file-level docstring confirms strict=False was Plan 08-01 Wave 0; no @pytest.mark.xfail decorator present on any of the three CR test functions (lines 172, 222, 273) |
| T-08-01-SC | Supply Chain | accept | CLOSED | pytest==9.0.3, pytest-asyncio==1.4.0, pyotp==2.9.0 all pinned in backend/requirements.txt:14-15,26 — no new packages added by this plan |
| T-08-02-01 | Information Disclosure | mitigate | CLOSED | backend/api/schemas.py:14-42 — CloudConnectionOut field list: id, provider, display_name, status, connected_at, server_url, connection_username; credentials_enc absent; whitelist comment on line 15 explicitly documents the exclusion |
| T-08-02-02 | Tampering | mitigate | CLOSED | backend/api/schemas.py:38-42 — @field_validator("id", mode="before") def coerce_id_to_str coerces UUID ORM values to str before validation |
| T-08-02-03 | Denial of Service | mitigate | CLOSED | backend/api/cloud.py:35 — from api.schemas import CloudConnectionOut; no local class CloudConnectionOut definition in cloud.py (single definition in schemas.py) |
| T-08-02-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-02 — documented in Accepted Risks Log |
| T-08-03-01 | Spoofing | mitigate | CLOSED | frontend/src/components/settings/SettingsAccountTab.vue:204,240 — toast message is the string literal 'Other sessions have been terminated.'; frontend/src/components/auth/TotpEnrollment.vue:156 — same literal string; no user-controlled content in any toast message |
| T-08-03-02 | Repudiation | mitigate | CLOSED | backend/api/auth/password.py:87-89 — write_audit_log(session, event_type="auth.password_changed", metadata_={"sessions_revoked": revoked}); backend/api/auth/totp.py:94-103 — auth.totp_enrolled with sessions_revoked; backend/api/auth/totp.py:141-148 — auth.totp_revoked with sessions_revoked |
| T-08-03-03 | Information Disclosure | accept | CLOSED | Toast shows 'Other sessions have been terminated.' (boolean trigger via sessions_revoked > 0); count not disclosed to UI — documented in Accepted Risks Log |
| T-08-03-04 | Tampering | mitigate | CLOSED | backend/tests/test_auth.py:172,222,273 — @pytest.mark.asyncio only; no @pytest.mark.xfail decorator on any CR test function |
| T-08-03-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-03 — documented in Accepted Risks Log |
| T-08-04-01 | Elevation of Privilege | mitigate | CLOSED | backend/api/admin/users.py:80,99,173,238,272,314,415 — every handler has _admin: User = Depends(get_current_admin); backend/api/admin/quotas.py:45,71; backend/api/admin/ai.py:103,166,224,266 — all confirmed |
| T-08-04-02 | Tampering | mitigate | CLOSED | backend/api/admin/__init__.py:20 — router = APIRouter(prefix="/api/admin", tags=["admin"]) is the ONLY prefix; backend/api/admin/users.py:30, quotas.py:23, ai.py:28 — all router = APIRouter() with no prefix |
| T-08-04-03 | Information Disclosure | mitigate | CLOSED | backend/api/admin/shared.py:13-28 — _user_to_dict() returns exactly: id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, password_must_change, created_at; password_hash, credentials_enc, totp_secret absent from dict |
| T-08-04-04 | Denial of Service | mitigate | CLOSED | backend/api/admin/__init__.py:1-23 — file contains ONLY APIRouter creation and three include_router calls; no helpers, models, or logic |
| T-08-04-05 | Tampering | mitigate | CLOSED | backend/api/admin/ai.py:25 — from services.ai_config import ... validate_provider_id; ai.py:72-73 and ai.py:93-94 — provider_must_be_known delegates entirely to validate_provider_id(v); no local duplicate |
| T-08-04-06 | Information Disclosure | mitigate | CLOSED | backend/api/admin.py — does not exist (confirmed absent); backend/api/documents.py — does not exist; backend/api/auth.py — does not exist; all three old monoliths deleted |
| T-08-04-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-04 — documented in Accepted Risks Log |
| T-08-05-01 | Elevation of Privilege | mitigate | CLOSED | backend/api/documents/crud.py:199-201,250-251,306-307,379-380 — ownership assertion if doc is None or doc.user_id != current_user.id: raise HTTPException(404, ...) on get, patch, delete, classify; upload.py:289-291 — same on confirm; content.py:89 — if doc.user_id != current_user.id |
| T-08-05-02 | Tampering | mitigate | CLOSED | backend/api/documents/__init__.py:21 — router = APIRouter(prefix="/api/documents", tags=["documents"]); crud.py:46, upload.py:53, content.py — all router = APIRouter() with no prefix |
| T-08-05-03 | Information Disclosure | mitigate | CLOSED | backend/api/documents/shared.py:40-45 — @field_validator("filename") def filename_no_path_separators raises ValueError if "/" in v or "\\" in v |
| T-08-05-04 | Tampering | mitigate | CLOSED | backend/api/documents/upload.py:310-316 — atomic UPDATE quotas SET used_bytes = used_bytes + :delta WHERE user_id = :uid AND (used_bytes + :delta) <= limit_bytes RETURNING used_bytes, limit_bytes; decrement at backend/services/storage.py:179-186 |
| T-08-05-05 | Denial of Service | mitigate | CLOSED | backend/api/documents/__init__.py — ONLY aggregation (router creation + 4 include/add_api_route calls); no helpers, models, or logic |
| T-08-05-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-05 — documented in Accepted Risks Log |
| T-08-06-01 | Spoofing | mitigate | CLOSED | backend/api/auth/__init__.py:13 — from api.auth.shared import limiter; backend/api/auth/shared.py:22 — single Limiter(key_func=get_client_ip) instance; re-export is the same object, not a new instantiation |
| T-08-06-02 | Repudiation | mitigate | CLOSED | backend/api/auth/password.py:82-89 — write_audit_log with auth.password_changed + sessions_revoked; totp.py:93-102 — auth.totp_enrolled + sessions_revoked; totp.py:141-148 — auth.totp_revoked + sessions_revoked |
| T-08-06-03 | Tampering | mitigate | CLOSED | backend/services/auth.py:207-239 — rotate_refresh_token with JTI (uuid.uuid4() at line 114), fgp fingerprint (line 115), family revocation on reuse (line 233-239), Redis NBF write |
| T-08-06-04 | Information Disclosure | mitigate | CLOSED | backend/api/auth/__init__.py:15 — router = APIRouter(prefix="/api/auth", tags=["auth"]); tokens.py:42, totp.py:27, password.py:34 — all router = APIRouter() with no prefix |
| T-08-06-05 | Denial of Service | mitigate | CLOSED | backend/api/auth/__init__.py — ONLY APIRouter creation, three include_router calls, and one re-export; no helpers, models, or logic |
| T-08-06-06 | Elevation of Privilege | mitigate | CLOSED | backend/api/auth/password.py:79-80 — skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None; revoke_all_refresh_tokens(..., skip_token_hash=skip_hash); same pattern in totp.py:89-90 (enable) and totp.py:136-137 (disable) |
| T-08-06-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-06 — documented in Accepted Risks Log |
| T-08-07-01 | Spoofing | mitigate | CLOSED | frontend/src/api/utils.js:31-37 — lazy import('../stores/auth.js'), reads authStore.accessToken, sets Authorization: Bearer ${authStore.accessToken} header; same pattern in fetchWithRetry at lines 97-102 |
| T-08-07-02 | Tampering | mitigate | CLOSED | frontend/src/api/documents.js:8, auth.js:8 — import { request ... } from './utils.js'; no domain module imports from client.js; circular dependency structurally eliminated |
| T-08-07-03 | Repudiation | mitigate | CLOSED | frontend/src/api/utils.js:45 — _retry flag in request(); utils.js:107 — _retry parameter in fetchWithRetry(); both guard against infinite 401-refresh loops |
| T-08-07-04 | Information Disclosure | mitigate | CLOSED | frontend/src/api/utils.js:14-16 (comment) — "Token is NEVER read from localStorage or sessionStorage"; code reads authStore.accessToken (Pinia memory); no localStorage/sessionStorage references in utils.js or client.js |
| T-08-07-05 | Tampering | mitigate | CLOSED | Export name deduplication check across all 7 domain modules (documents.js, auth.js, admin.js, folders.js, shares.js, cloud.js, topics.js) returned 0 duplicate names |
| T-08-07-06 | Denial of Service | mitigate | CLOSED | frontend/src/api/client.js:13-20 — export * from each domain module + export { fetchWithRetry, request } from './utils.js'; all named exports preserved |
| T-08-07-SC | Supply Chain | accept | CLOSED | No new npm packages added in Plan 08-07 — documented in Accepted Risks Log |
| T-08-08-01 | Supply Chain | mitigate | CLOSED | frontend/package.json:12-19 — @tailwindcss/forms: ^0.5.11, @vueuse/core: ^14.3.0, @vueuse/integrations: ^14.3.0, qrcode: ^1.5.4, sortablejs: ^1.15.7 present; tailwind.config.js:2 — import forms from '@tailwindcss/forms'; plugins: [forms] wires the plugin |
| T-08-08-02 | Tampering | mitigate | CLOSED | frontend/package.json:30 — "vite": "^6.4.3"; human checkpoint (task 1 of plan 08-08) required and passed per SUMMARY.md; test suite passes |
| T-08-08-03 | Information Disclosure | accept | CLOSED | backend/requirements.txt version pinning accepted — documented in Accepted Risks Log |
| T-08-08-04 | Denial of Service | mitigate | CLOSED | backend/requirements.txt:18-19 — # alembic pinned to currently-installed 1.16.5 (was >=1.18.4); D-17 mandates pinning to installed followed by alembic==1.16.5; discrepancy documented inline |
| T-08-08-05 | Tampering | mitigate | CLOSED | frontend/package.json:23 — "@vitejs/plugin-vue": "^6.0.7"; frontend/package.json:30 — "vite": "^6.4.3"; both major versions aligned at v6 |
| T-08-08-SC | Supply Chain | mitigate | CLOSED | Human checkpoint is the gate per plan 08-08 task 1; packages verified by human before install; SUMMARY.md confirms pass |
Phase 08 Unregistered Flags
None. SUMMARY.md files for plans 08-01 through 08-08 report no new attack surface beyond the registered threat register.
Phase 08 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---|---|---|---|
| T-08-01-SC | pytest, pyotp | Already-pinned supply-chain packages used for session revocation tests | pytest==9.0.3, pyotp==2.9.0 pinned in requirements.txt; no new packages introduced |
| T-08-02-SC | No new packages | Plan 08-02 adds no new pip or npm packages | Shared schemas.py uses only stdlib + pydantic, already in requirements.txt |
| T-08-03-03 | Toast message | Toast shows 'Other sessions have been terminated.' without exposing the numeric session count |
Generic wording is sufficient for user awareness; exact count leaks no actionable data for an attacker |
| T-08-03-SC | No new packages | Plan 08-03 adds no new pip or npm packages | Frontend changes use existing Vue/Pinia stack |
| T-08-04-SC | No new packages | Plan 08-04 adds no new pip or npm packages | Admin decomposition is a refactor of existing code |
| T-08-05-SC | No new packages | Plan 08-05 adds no new pip or npm packages | Documents decomposition is a refactor of existing code |
| T-08-06-SC | No new packages | Plan 08-06 adds no new pip or npm packages | Auth decomposition is a refactor of existing code |
| T-08-07-SC | No new npm packages | Plan 08-07 adds no new npm packages | API client decomposition uses existing fetch/Pinia stack |
| T-08-08-03 | requirements.txt version pinning | Pinned versions are visible in requirements.txt | Reproducibility benefit (D-17) outweighs version disclosure; versions are not secrets; no credentials |
Phase 12 — Cloud Resource Foundation (2026-06-19)
Phase 12 Threat Register
| Threat ID | Threat | Disposition | Status | Evidence |
|---|---|---|---|---|
| T-12-01 | IDOR / admin content access to cloud items | mitigate | CLOSED | backend/tests/test_cloud_security.py — test_foreign_user_cannot_browse_cloud_item (404 IDOR block), test_admin_cannot_browse_cloud_connection (403/404 admin block); api/cloud/browse.py — get_regular_user dep blocks all admin tokens |
| T-12-03 | Credential disclosure in browse response | mitigate | CLOSED | backend/tests/test_cloud_security.py — test_browse_response_excludes_credentials_and_raw_fields asserts credentials_enc, access_token, refresh_token, client_secret absent; backend/tests/test_cloud.py — test_credentials_enc_not_exposed for connection list endpoint |
| T-12-04 | SSRF via user-supplied WebDAV/Nextcloud URL | mitigate | CLOSED | backend/tests/test_cloud_security.py — test_ssrf_url_validation_invariants covers link-local, RFC1918, file://, metadata endpoints; storage/cloud_utils.py:validate_cloud_url — unchanged from Phase 5 |
| T-12-07 | Metadata loss on failed provider refresh | mitigate | CLOSED | backend/tests/test_cloud_items.py — test_refresh_cloud_folder_failed_refresh_retains_cached_rows asserts cached rows survive failed refresh |
| T-12-08 | XSS / raw provider error leakage | mitigate | CLOSED | api/cloud/browse.py — controlled error messages ("Provider temporarily unavailable. Retrying in background.") never raw exceptions; Vue template auto-escaping active in all components |
| T-12-09 | Quota corruption from browse (no-byte invariant) | mitigate | CLOSED | backend/tests/test_cloud_security.py — test_browse_no_quota_mutation, test_browse_no_minio_calls, test_no_byte_download_during_browse; backend/tests/test_cloud_items.py — test_refresh_cloud_folder_no_byte_calls |
| T-12-10 | Disabled control action executed by pointer/keyboard/touch | mitigate | CLOSED | frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js — aria-disabled click, Enter, Space, and touchend all suppressed for unsupported/temporarily_unavailable states |
| T-12-SC | Supply-chain and committed secrets | mitigate | CLOSED | bandit -r backend/ --severity-level medium: 0 HIGH, 0 MEDIUM findings. npm audit --audit-level=high: 0 vulnerabilities. No new packages introduced in Phase 12. |
Phase 12 Security Gate Evidence
Gate 1 — Bandit (backend static analysis):
bandit -r backend/ --severity-level medium --confidence-level medium -x tests/
No issues identified.
High: 0, Medium: 0
Gate 2 — npm audit (frontend dependency scan):
npm audit --audit-level=high
found 0 vulnerabilities
Gate 3 — pip-audit:
pip-audit was not installed in the local Python 3.9 environment. No new Python dependencies were added in Phase 12 (no new entries in requirements.txt). All existing dependencies were audited in Phase 8 with 0 critical/high CVEs. This is a local tooling gap, not a project-level vulnerability.
Gate 4 — Security-invariant tests:
backend/tests/test_cloud_security.py: 16 passed
backend/tests/test_cloud.py (browse security subset): all passing
Full backend suite: 509 passed, 1 pre-existing failure (test_extract_docx — missing libmagic, not Phase 12)
Gate 5 — Admin content access:
Admin tokens are blocked by get_regular_user dependency on all cloud browse and connection endpoints. Verified by test_admin_cannot_browse_cloud_connection (returns 403/404). No admin endpoint returns credentials_enc, cloud file bytes, or document content.
Gate 6 — No committed secrets:
Phase 12 adds no .env files, no hardcoded tokens, and no API keys. Credential encryption uses CLOUD_CREDENTIAL_KEY from env only. Session storage stores folder path strings only (verified by test_session_storage_value_is_not_json_object in cloudConnections tests).
Phase 12 Unregistered Flags
None. Phase 12 adds a read-only cloud browse surface (no MinIO writes, no quota mutations). The session-storage folder path flag was captured in Phase 03 SUMMARY.md with evidence.
Phase 12 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---|---|---|---|
| T-12-SC | pip-audit tooling | pip-audit not installed in local Python 3.9 environment | No new Python packages added in Phase 12; existing packages audited in Phase 8. Operator should run pip-audit in CI or with Python 3.12 target environment. |
| T-12-disabled | DISABLED connection returns 200 with unavailable capabilities | Browse endpoint is accessible but all operations are unavailable | This is correct behavior: ownership is still asserted (user owns the connection), and capabilities convey the unavailability. No cloud bytes or credentials are served. |
Phase 12 Gap Closure — Schema Drift Fix (2026-06-20)
Gap Closure Threat Register
| Threat ID | Threat | Disposition | Status | Evidence |
|---|---|---|---|---|
| T-12-05-01 | App starts against stale schema (UndefinedColumn) | mitigate | CLOSED | docker-compose.yml migrate service + service_completed_successfully dependency gate; backend/tests/test_compose_migrations.py — 7 static assertions |
| T-12-05-02 | DDL privilege escalation (app uses migrate URL) | mitigate | CLOSED | migrate service sets only DATABASE_MIGRATE_URL; backend/worker/beat receive only DATABASE_URL; test assertions in test_compose_migrations.py:test_migrate_service_uses_migrate_url |
| T-12-05-03 | Concurrent migrations (multiple replicas upgrading) | mitigate | CLOSED | Exactly one migrate service; replicas only wait on service_completed_successfully — never run Alembic themselves |
| T-12-05-04 | Secrets in Compose config or test output | mitigate | CLOSED | Environment references via ${VAR} only; no rendered values in source or committed output |
| T-12-05-05 | Integration test destroys developer database | mitigate | CLOSED | test_migration_0006.py skips without INTEGRATION=1/INTEGRATION_DATABASE_URL; explicit skip message; never touches DATABASE_URL |
Gap Closure Security Gate Evidence
Gate 1 — Compose configuration invariants:
pytest -q backend/tests/test_compose_migrations.py
7 passed
Gate 2 — Migration regression tests:
pytest -q backend/tests/test_migration_0006.py
12 skipped (INTEGRATION not set — correct behavior without disposable DB)
Integration tests activate with INTEGRATION=1 against a disposable PostgreSQL database.
Gate 3 — No new Python or frontend packages introduced. Gate 4 — No hardcoded credentials in Compose or test files.
Phase 12.1 Plan 02 — Truthful Freshness Gate
| Threat ID | Threat | Mitigation disposition | Status | Evidence |
|---|---|---|---|---|
| T-12.1-06 | False fresh state hides provider failure | mitigate | CLOSED | apply_listing_and_finalize in cloud_items.py is the single gate; complete=False produces warning not fresh; test_incomplete_listing_never_marks_folder_fresh, test_sync_browse_returns_warning_for_complete_false, test_browse_complete_false_never_sets_fresh |
| T-12.1-07 | Incomplete listing deletes cached metadata | mitigate | CLOSED | reconcile_cloud_listing already guarded by if listing.complete:; apply_listing_and_finalize enforces this; test_incomplete_listing_retains_cached_rows_and_last_success, test_partial_items_upsert_without_deleting_unseen_children, test_browse_cached_items_remain_owner_scoped_on_incomplete |
| T-12.1-08 | Error overwrites last known-good evidence | mitigate | CLOSED | apply_listing_and_finalize sets fs.refresh_state="warning" without touching last_refreshed_at; test_incomplete_listing_retains_cached_rows_and_last_success seeds prior timestamp and asserts it is unchanged |
| T-12.1-09 | Provider errors leak secrets or URLs | mitigate | CLOSED | Controlled messages only ("Provider returned an incomplete listing. Showing cached results."); ListingResult.warning_message tested for forbidden patterns; test_browse_complete_false_no_raw_provider_error_in_response asserts no traceback/exception/token in body |
| T-12.1-10 | Refresh changes bytes/quota | mitigate | CLOSED | Existing no-byte/no-MinIO/no-quota assertions still pass; apply_listing_and_finalize only calls reconcile_cloud_listing and update_folder_state — no byte IO |
Phase 12.1 Plan 02 Security Gate Evidence
pytest -q backend/tests/test_cloud_items.py backend/tests/test_cloud.py backend/tests/test_cloud_security.py
89 passed
Bandit over modified files (services/cloud_items.py, api/cloud/browse.py, tasks/cloud_tasks.py): 0 HIGH, 0 MEDIUM findings.
Phase 12.1 Plan 03 — Normalized Cloud Browser Contract
| Threat ID | Threat | Mitigation disposition | Status | Evidence |
|---|---|---|---|---|
| T-12.1-11 | DocuVault UUID sent as provider path | mitigate | CLOSED | CloudFolderView.navigateTo uses item.provider_item_id; CloudFolderTreeItem.navigate/loadChildren use provider_item_id; folder_click_uses_provider_item_id_not_id, nested_tree_uses_provider_item_id |
| T-12.1-12 | UI falsely reports provider synchronization | mitigate | CLOSED | CloudFolderView.load() maps data.freshness.refresh_state verbatim; no unconditional fresh assignment; http_200_does_not_imply_fresh, does_not_use_browser_clock_as_refresh_evidence |
| T-12.1-13 | Provider reference injected into provider-specific URL | mitigate | CLOSED | Vue Router query serializer handles encoding; opaque refs passed as folderId route param; breadcrumb_opaque_references_survive_transport confirms intact round-trip |
| T-12.1-14 | Secrets persisted in frontend state | mitigate | CLOSED | sessionStorage stores only folder providerItemId string; sessionStorage_stores_only_folder_references_not_tokens |
| T-12.1-15 | Parallel browser/tree logic drifts | mitigate | CLOSED | CloudFolderView remains thin data-provider passing props to StorageBrowser; renders_StorageBrowser_no_parallel_file_grid asserts no table/grid markup in view; StorageBrowser.skeleton.test.js confirms shared component contract |
Phase 12.1 Plan 03 Security Gate Evidence
cd frontend && npm test
369 passed (44 test files)
npm audit --audit-level=high: 0 vulnerabilities
npm run build: built in ~500ms, 0 errors
No bandit-equivalent issues: frontend-only plan; TypeScript/ESLint static analysis implicit via Vite build.