Conversation
… sidecar trust - Server: Remove silent non-SQLite fallback in per-user DB routing (fail-closed) - Server: Block /api/db/* endpoints in shared-database mode - Server: Block vault import in shared-database mode - Server: Disable config mutation in shared-database mode - Server: Add sidecar nonce middleware for desktop-to-server trust - Desktop: Remove rehype-raw from Chat markdown (prevent XSS) - Desktop: Re-enable restrictive CSP in tauri.conf.json - Desktop: Move unlock token to memory-only (remove localStorage persistence) - Desktop: Remove Reveal Key / Copy flow from SecuritySettings - Desktop: Add sidecar nonce discovery and injection in API client - Rust: Generate boot-time nonce, pass to sidecar, validate in health check Co-authored-by: G9000 <11317652+G9000@users.noreply.github.com>
…nd nonce 13 new tests covering: - is_sqlite_mode() helper - Per-user DB routing fail-closed for non-SQLite - /api/db/* blocked in shared-database mode - Vault import blocked in shared-database mode - Config mutation blocked in shared-database mode - Config mutation allowed in SQLite mode (regression) - Health endpoint nonce exposure/omission Co-authored-by: G9000 <11317652+G9000@users.noreply.github.com>
…e review feedback) Co-authored-by: G9000 <11317652+G9000@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Security-hardening across the FastAPI server and Tauri desktop client to reduce tenant-isolation footguns, eliminate XSS-by-markdown, stop persisting unlock tokens, tighten desktop CSP, and introduce a sidecar “nonce” mechanism intended to bind the UI to the sidecar it launched.
Changes:
- Server: add
is_sqlite_mode(), fail-closed per-user DB routing, and add sidecar-nonce middleware +/healthnonce exposure. - Desktop: remove
rehype-raw, re-enable restrictive CSP, store unlock token in-memory only, remove “Session Unlock Key” UI, and add nonce discovery + header injection. - Tests: add
test_security_hardening.pycovering SQLite-mode behavior and shared-mode blocks.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/api-client/src/index.ts | Adds optional nonce getter and injects x-anima-nonce into normal + streaming requests. |
| bun.lock | Removes rehype-raw dependency from lockfile. |
| apps/server/tests/test_security_hardening.py | Adds tests for SQLite-mode checks, shared-mode blocking behavior, and /health nonce presence/absence. |
| apps/server/src/anima_server/main.py | Adds SidecarNonceMiddleware and optionally includes nonce in /health. |
| apps/server/src/anima_server/db/session.py | Introduces is_sqlite_mode() and makes per-user DB routing raise in non-SQLite mode. |
| apps/server/src/anima_server/config.py | Adds sidecar_nonce to Settings (env: ANIMA_SIDECAR_NONCE). |
| apps/server/src/anima_server/api/routes/vault.py | Blocks vault import in non-SQLite mode. |
| apps/server/src/anima_server/api/routes/db.py | Blocks DB viewer endpoints in non-SQLite mode. |
| apps/server/src/anima_server/api/routes/config.py | Blocks config mutation in non-SQLite mode. |
| apps/desktop/src/pages/settings/SecuritySettings.tsx | Removes UI that reveals/copies the unlock token. |
| apps/desktop/src/pages/Chat.tsx | Removes raw HTML rendering by dropping rehype-raw. |
| apps/desktop/src/lib/runtime.ts | Adds /health nonce discovery helper. |
| apps/desktop/src/lib/api.ts | Moves unlock token storage to memory; adds sidecar nonce storage/getter. |
| apps/desktop/src/context/AuthContext.tsx | Discovers nonce on boot and stores it before calling API. |
| apps/desktop/src-tauri/tauri.conf.json | Re-enables restrictive CSP with a narrow connect-src allowlist. |
| apps/desktop/src-tauri/src/lib.rs | Generates nonce, injects it into sidecar env, and validates it via healthcheck response. |
| apps/desktop/package.json | Removes rehype-raw dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
apps/server/src/anima_server/main.py
Outdated
| # Paths exempt from sidecar-nonce validation (health check itself must be | ||
| # reachable so the desktop can discover the nonce). | ||
| _NONCE_EXEMPT_PATHS = frozenset({"/health", "/api/health"}) | ||
|
|
||
|
|
||
| class SidecarNonceMiddleware(BaseHTTPMiddleware): | ||
| """Reject requests that do not carry the expected sidecar nonce. | ||
|
|
||
| When ``ANIMA_SIDECAR_NONCE`` is set, every request (except the health | ||
| endpoints) must include the header ``x-anima-nonce`` with the matching | ||
| value. This binds the desktop client to the exact sidecar process it | ||
| launched, preventing rogue localhost processes from being trusted. |
apps/server/src/anima_server/main.py
Outdated
| if settings.sidecar_nonce: | ||
| resp["nonce"] = settings.sidecar_nonce | ||
| return resp |
apps/server/src/anima_server/main.py
Outdated
| # Sidecar nonce enforcement — must be added before CORS so the | ||
| # nonce check runs on every incoming request. |
| - ``PUT /api/config/{user_id}`` is blocked in shared-database mode | ||
| - Sidecar nonce middleware enforcement | ||
| - Health endpoint exposes nonce when configured |
apps/desktop/src/lib/runtime.ts
Outdated
| export async function discoverSidecarNonce(): Promise<string | null> { | ||
| try { | ||
| const resp = await fetch(`${API_ORIGIN}/health`); | ||
| if (!resp.ok) return null; | ||
| const data = (await resp.json()) as { nonce?: string }; | ||
| return data.nonce ?? null; |
| if not is_sqlite_mode(): | ||
| raise HTTPException( | ||
| status_code=status.HTTP_403_FORBIDDEN, | ||
| detail="Vault import is disabled in shared-database mode to protect tenant isolation.", | ||
| ) |
| if not is_sqlite_mode(): | ||
| raise HTTPException( | ||
| status_code=status.HTTP_403_FORBIDDEN, | ||
| detail="Config mutation is disabled in shared-database mode. " | ||
| "Process-global config cannot safely be changed per-user.", |
| ], | ||
| "security": { | ||
| "csp": null | ||
| "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' http://127.0.0.1:3031 https://translate.googleapis.com; font-src 'self' data:" |
apps/desktop/src-tauri/src/lib.rs
Outdated
| /// Generate a random 32-byte hex nonce for sidecar authentication. | ||
| fn generate_nonce() -> String { | ||
| // Read from the OS random source for cryptographic strength. | ||
| // Works on Linux, macOS, and other Unix-like systems. | ||
| #[cfg(unix)] | ||
| { | ||
| use std::fs::File; | ||
| use std::io::Read; | ||
| if let Ok(mut f) = File::open("/dev/urandom") { | ||
| let mut buf = [0u8; 32]; | ||
| if f.read_exact(&mut buf).is_ok() { | ||
| return buf.iter().map(|b| format!("{:02x}", b)).collect(); | ||
| } | ||
| } | ||
| } | ||
| // Fallback: hash-based nonce using multiple entropy sources. | ||
| use std::collections::hash_map::RandomState; | ||
| use std::hash::{BuildHasher, Hasher}; | ||
| let s = RandomState::new(); | ||
| let mut h = s.build_hasher(); | ||
| h.write_u128(std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .unwrap_or_default() | ||
| .as_nanos()); | ||
| let a = h.finish(); | ||
| let mut h2 = s.build_hasher(); | ||
| h2.write_u64(a); | ||
| h2.write_usize(std::process::id() as usize); | ||
| let b = h2.finish(); | ||
| format!("{:016x}{:016x}", a, b) |
apps/desktop/src-tauri/src/lib.rs
Outdated
| // Verify the response contains the expected nonce to confirm this is our | ||
| // sidecar, not a rogue process on the same port. | ||
| if !expected_nonce.is_empty() { | ||
| if !response.contains(expected_nonce) { | ||
| return false; |
|
@copilot can you work on resolving the pr comments? |
… getrandom, JSON parsing - Convert RuntimeError to HTTPException(403) in get_user_database_url for clean fail-closed behavior instead of 500 - Create require_sqlite_mode FastAPI dependency that runs before get_db, ensuring 403 is returned before DB session creation fails - Remove nonce from /health response — nonce is now delivered exclusively via Tauri IPC (get_sidecar_nonce command) so local processes can't obtain it - Fix middleware ordering comment to accurately reflect Starlette's reverse-add semantics - Replace /dev/urandom + hash fallback with getrandom crate for cross-platform CSPRNG nonce generation - Parse JSON properly in Rust health check instead of brittle string contains - Add http://localhost:3031 to CSP connect-src for VITE_API_BASE_URL compat - Add nonce middleware enforcement tests (missing header → 403, correct header → 200, wrong header → 403, health exempt) - Update test to verify /health does NOT expose nonce Co-authored-by: G9000 <11317652+G9000@users.noreply.github.com>
All PR review comments have been addressed in commit
All 344 tests passing (327 original + 17 security hardening). |
Co-authored-by: G9000 <11317652+G9000@users.noreply.github.com>
|
@copilot you address it all? Resolve conversation then so I know |
Yes — all 13 review comments are resolved. Here's the summary:
346/346 tests passing. |
Several security issues in the active FastAPI + Tauri stack: silent shared-DB fallback breaks tenant isolation, chat renders raw HTML, unlock tokens persist in localStorage, and the desktop trusts any process on localhost:3031.
Server: tenant isolation (fail-closed)
db/session.py:get_user_database_url()now raisesHTTPException(403)instead of silently falling back to the shared URL in non-SQLite mode. Addedis_sqlite_mode()helper.api/deps/db_mode.py: Newrequire_sqlite_modeFastAPI dependency that runs beforeget_db, ensuring endpoints return a clean 403 before DB session creation is attempted in shared-database mode./api/db/*: All endpoints return 403 in shared-database mode viaDepends(require_sqlite_mode)— raw SQL access to a shared Postgres is not safe./api/vault/import: Blocked in shared-DB mode viaDepends(require_sqlite_mode)—restore_database_snapshotdoes bulk DELETE+INSERT across all users.PUT /api/config/{user_id}: Blocked in shared-DB mode viaDepends(require_sqlite_mode)— process-global config mutation is not per-user safe.Server: sidecar nonce middleware
SidecarNonceMiddlewareenforcesx-anima-nonceheader on all requests whenANIMA_SIDECAR_NONCEis set./healthis exempt but does not expose the nonce — the nonce is only delivered to the frontend via trusted Tauri IPC.sidecar_nonceadded toSettings.Desktop: XSS and CSP
rehype-rawfromChat.tsx— markdown no longer renders arbitrary HTML.tauri.conf.json(default-src 'self';connect-srcallowlists bothhttp://127.0.0.1:3031andhttp://localhost:3031forVITE_API_BASE_URLcompatibility, plus Google Translate).Desktop: token and key exposure
api.ts: Unlock token stored in memory only.localStorage.setItemremoved;clearUnlockTokenstill purges legacy entries.SecuritySettings.tsx: Removed the "Session Unlock Key" section (Reveal / Copy / Refresh).Desktop + Rust: sidecar trust
lib.rs: Generates a cryptographic nonce using thegetrandomcrate (cross-platform CSPRNG) at boot, passes viaANIMA_SIDECAR_NONCEenv var. Health check parses JSON response properly withserde_jsoninstead of brittle string matching.lib.rs: Newget_sidecar_nonceTauri IPC command delivers the nonce to the frontend via a trusted channel — the nonce is never exposed over HTTP.runtime.ts/AuthContext.tsx: Discovers nonce via Tauri IPC (invoke("get_sidecar_nonce")) at startup, injects viax-anima-nonceon all API requests.api-client: AddedgetNonceoption toApiClientOptions, injected into bothrequest()andstreamChat().Tests
19 new tests in
test_security_hardening.pycoveringis_sqlite_mode,require_sqlite_modedependency, shared-DB blocking for db/vault/config routes, SQLite-mode regression, nonce middleware enforcement (missing/wrong/correct header, health exempt), and verification that/healthdoes not expose the nonce. 346/346 passing.📍 Connect Copilot coding agent with Jira, Azure Boards or Linear to delegate work to Copilot in one click without leaving your project management tool.