Files
kite/SECURITY.md
T
curo1305 eaa3399ec0 docs: add shared module map to CLAUDE.md, SECURITY.md, planning artifacts
- CLAUDE.md: add Code Standards section with backend and frontend shared
  module maps, component architecture rules, duplication checklist, and
  no-dead-code enforcement rule
- SECURITY.md: Phase 02 + 03 security audit results (all threats CLOSED)
- .planning: update milestone audit, config, and add plan/UAT files for
  phases 01, 02-06, and 06.2-05

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:10:59 +02:00

20 KiB
Raw Blame History

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:93payload.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:310code_hash=hash_password(code) (Argon2); services/auth.py:338verify_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:360sha1[: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-371httpx timeout=5.0, except Exception: logger.warning(…); return False (fail-open)
T-02-07 EoP mitigate CLOSED deps/auth.py:87if 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-26PyJWT>=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:100samesite="strict" on refresh cookie (api/auth.py:100); main.py:47-61OriginValidationMiddleware rejects non-GET/HEAD/OPTIONS requests with Origin not in settings.cors_origins
T-02-12 Info Disclosure accept CLOSED Accepted: stores/auth.jsaccessToken = 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-40SecurityHeadersMiddleware sets Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff on every response
T-02-15 Tampering mitigate CLOSED main.py:124allow_origins=settings.cors_origins; grep for allow_origins=["*"] returns 0 matches
T-02-16 EoP mitigate CLOSED api/auth.py:259-260if 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-126decode_password_reset_token checks payload.get("typ") != "password-reset" raises ValueError
T-02-21 EoP mitigate CLOSED api/auth.py:730POST /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.pygrep -c get_current_admin returns 12; every handler has _admin: User = Depends(get_current_admin)
T-02-26B Spoofing mitigate CLOSED services/auth.py:330BackupCode.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,377status_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:189v-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:255password_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-174v-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:42meta: { requiresAdmin: true } on /admin route; router/index.js:91-93if (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:265await 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-88null_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:221token = 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-113suffix = 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:327size = 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-351UPDATE 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-177cleanup_abandoned_uploads Celery task exists; _cleanup_abandoned() selects Document.status == "pending" and Document.created_at < cutoff (1 hour); backend/celery_app.py:43-46beat_schedule entry with _timedelta(minutes=30)
T-03-09 Info Disclosure mitigate CLOSED docker-compose.yml:26MINIO_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-60self._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-109get_regular_user raises HTTP 403 if user.role == "admin"; backend/api/documents.pyDepends(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:44storage.load_topics_for_user(session, user_id=current_user.id); backend/api/topics.py:64storage.create_topic(..., user_id=current_user.id)
T-03-14 EoP mitigate CLOSED backend/api/admin.py:602-622POST /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:113object_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:35security = 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-64user = 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-25xhr.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,30item.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:70const 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-149fetchQuota() 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.


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.