Files
kite/SECURITY.md
T
curo1305 46b7ea6c12 test(12.1): verify live cloud browse integration
Phase 12.1 close — docs, version, and security evidence:
- Bump version 0.2.5 → 0.2.6 in backend/main.py, frontend/package.json
- CLAUDE.md: update current state to Phase 12.1 complete v0.2.6
- README.md: add Phase 12.1 corrections, live smoke variable names, Phase 13/14 exclusions
- RUNBOOK.md: add live smoke invocation, warning semantics table, fixture reconciliation procedure
- SECURITY.md: add Phase 12.1 Plan 04 threat table (T-12.1-16..20, T-12.1-SC) and gate evidence
- 12.1-VALIDATION.md: mark Plan 04 complete with final gate evidence
2026-06-22 09:46:25 +02:00

496 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SECURITY.md — Phase 02 + Phase 03
**Audit date:** 2026-06-01
**Phase 02:** users-authentication (plans 0106) — previously audited, result SECURED
**Phase 03:** document-migration-multi-user-isolation (plans 0105)
**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:5670` 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:3368` 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:4346` asserts cross-provider decrypt raises `InvalidToken`. |
| T-07-03 | Tampering | mitigate | CLOSED | Single atomic UPDATE at `backend/api/admin.py:902906`: `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:71115` 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:7480` 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:2534`: 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:103108`: if `stop_reason != "end_turn"` (covers `"refusal"` and `"max_tokens"`), sets `raw = ""` and calls `parse_classification("")`. `parse_classification("")` at `backend/ai/utils.py:1735` returns `ClassificationResult()` (empty, no exception). |
| T-07-09 | Denial of Service | accept | CLOSED | `/classify` endpoint at `backend/api/documents.py:707739` requires authentication (`get_regular_user`), ownership check (line 731732), 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:730732`: `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:10091059` 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:8186`; `self.retry()` is called in the sync layer only. Unit test `test_retry_backoff` at `backend/tests/test_document_tasks.py:2659` 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.vue` has 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.id` combined 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.py` uses `CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END` instead of `GREATEST(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: Request` parameter to `password_reset_confirm` signature
- Added `await request.app.state.redis.set(f"user_nbf:{user.id}", ...)` before `session.commit()`
- Added test `test_password_reset_confirm_writes_user_nbf_to_redis` — PASSED
- All 5 security-event handlers now write `user_nbf` consistently
---
## 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.
---
## Phase 12.1 Plan 04 — Live Smoke, Exact Acceptance, and Phase Close
| Threat ID | Threat | Mitigation disposition | Status | Evidence |
|-----------|--------|----------------------|--------|---------|
| T-12.1-16 | Live credentials/full URL leak through pytest/logs | mitigate | CLOSED | `_load_live_credentials()` reads env vars in process memory only; `_assert_no_secrets_in_string` asserts captured output/logs on each test; no credential values in parametrize IDs, assertion diffs, or exception messages |
| T-12.1-17 | Live smoke mutates/downloads user data | mitigate | CLOSED | `_NetworkMethodGuard` blocks GET/PUT/POST/PATCH/DELETE/MKCOL/MOVE/COPY before dispatch; mutation method absence assertion on adapter object; no MinIO/quota change confirmed |
| T-12.1-18 | Live connection crosses user/admin boundary | mitigate | CLOSED | `test_nextcloud_connection_id_browse_as_designated_user` provisions isolated test DB user, foreign-user gets 403/404, admin gets 403/404 |
| T-12.1-19 | Count-only test accepts wrong root | mitigate | CLOSED | `test_nextcloud_expected_root_manifest` asserts exact set equality AND exact kind equality; `owner_confirmed: true` guard prevents fixture activation without explicit reconciliation |
| T-12.1-20 | Unexpected provider names become persisted sensitive metadata | mitigate | CLOSED | Diagnostic prints only `unexpected_count` (names withheld); fixture contains only confirmed names; no unexpected names in any committed artifact, log, or response |
| T-12.1-SC | Supply-chain or committed-secret regression | mitigate | CLOSED | Bandit 0 HIGH; npm audit 0 high/critical; gitleaks 3 findings all pre-existing in commits prior to Phase 12.1 (May 2026), none from Phase 12.1 files; no `.env` in git index |
### Phase 12.1 Plan 04 Security Gate Evidence
```
bandit -r backend/ -q: 0 HIGH, 0 MEDIUM (10 LOW — all pre-existing, none Phase 12.1)
npm audit --audit-level=high: 0 vulnerabilities
gitleaks detect --redact: 3 pre-existing findings (auth.test.js 2026-05-31,
test_cloud_utils.py 2026-05-28, test_auth_deps.py 2026-05-22); none in Phase 12.1 files
git ls-files '.env' '.env.*': only .env.example tracked
docker compose config --quiet: resolves all required variables without printing values
Backend suite: 595 pass, 1 pre-existing failure (test_extract_docx, ModuleNotFoundError unrelated)
Frontend suite: 376 pass (7 new rendered-flow tests)
Frontend build: clean
```
### Phase 12.1 Exclusions
Phase 12.1 scopes only read-only browse, freshness truthfulness, and cross-provider UI normalization:
- **Phase 13 mutations excluded:** upload, rename, move, delete, create-folder are not implemented
- **Phase 14 byte cache excluded:** no document content download, local copy, or cache layer
- No threat for cloud mutations (T-13-xx) or byte cache (T-14-xx) is evaluated in Phase 12.1