Skip to content

Security hardening: tenant isolation, XSS, token storage, sidecar trust#5

Merged
G9000 merged 6 commits intomainfrom
copilot/security-hardening-fastapi-tauri
Mar 15, 2026
Merged

Security hardening: tenant isolation, XSS, token storage, sidecar trust#5
G9000 merged 6 commits intomainfrom
copilot/security-hardening-fastapi-tauri

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 15, 2026

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 raises HTTPException(403) instead of silently falling back to the shared URL in non-SQLite mode. Added is_sqlite_mode() helper.
  • api/deps/db_mode.py: New require_sqlite_mode FastAPI dependency that runs before get_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 via Depends(require_sqlite_mode) — raw SQL access to a shared Postgres is not safe.
  • /api/vault/import: Blocked in shared-DB mode via Depends(require_sqlite_mode)restore_database_snapshot does bulk DELETE+INSERT across all users.
  • PUT /api/config/{user_id}: Blocked in shared-DB mode via Depends(require_sqlite_mode) — process-global config mutation is not per-user safe.
def get_user_database_url(user_id: int) -> str:
    if not is_sqlite_mode():
        raise HTTPException(
            status_code=403,
            detail="Per-user database routing requires SQLite mode. "
            "Shared-database deployments must not silently fall back to "
            "the shared URL — that would break tenant isolation.",
        )
    ...

Server: sidecar nonce middleware

  • New SidecarNonceMiddleware enforces x-anima-nonce header on all requests when ANIMA_SIDECAR_NONCE is set. /health is exempt but does not expose the nonce — the nonce is only delivered to the frontend via trusted Tauri IPC.
  • sidecar_nonce added to Settings.
  • Middleware ordering comment accurately reflects Starlette's reverse-add semantics (CORS is outermost, allowing OPTIONS preflights before the nonce check).

Desktop: XSS and CSP

  • Removed rehype-raw from Chat.tsx — markdown no longer renders arbitrary HTML.
  • Re-enabled restrictive CSP in tauri.conf.json (default-src 'self'; connect-src allowlists both http://127.0.0.1:3031 and http://localhost:3031 for VITE_API_BASE_URL compatibility, plus Google Translate).

Desktop: token and key exposure

  • api.ts: Unlock token stored in memory only. localStorage.setItem removed; clearUnlockToken still 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 the getrandom crate (cross-platform CSPRNG) at boot, passes via ANIMA_SIDECAR_NONCE env var. Health check parses JSON response properly with serde_json instead of brittle string matching.
  • lib.rs: New get_sidecar_nonce Tauri 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 via x-anima-nonce on all API requests.
  • api-client: Added getNonce option to ApiClientOptions, injected into both request() and streamChat().

Tests

19 new tests in test_security_hardening.py covering is_sqlite_mode, require_sqlite_mode dependency, shared-DB blocking for db/vault/config routes, SQLite-mode regression, nonce middleware enforcement (missing/wrong/correct header, health exempt), and verification that /health does 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.

Copilot AI and others added 3 commits March 15, 2026 14:50
… 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>
Copilot AI changed the title [WIP] Add security hardening for FastAPI server and Tauri app Security hardening: tenant isolation, XSS, token storage, sidecar trust Mar 15, 2026
Copilot AI requested a review from G9000 March 15, 2026 15:02
@G9000 G9000 requested a review from Copilot March 15, 2026 15:03
@G9000 G9000 marked this pull request as ready for review March 15, 2026 15:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + /health nonce 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.py covering 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.

Comment on lines +32 to +43
# 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.
Comment on lines +119 to +121
if settings.sidecar_nonce:
resp["nonce"] = settings.sidecar_nonce
return resp
Comment on lines +64 to +65
# Sidecar nonce enforcement — must be added before CORS so the
# nonce check runs on every incoming request.
Comment on lines +8 to +10
- ``PUT /api/config/{user_id}`` is blocked in shared-database mode
- Sidecar nonce middleware enforcement
- Health endpoint exposes nonce when configured
Comment on lines +19 to +24
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;
Comment on lines +37 to +41
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.",
)
Comment on lines +84 to +88
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:"
Comment on lines +17 to +46
/// 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)
Comment on lines +96 to +100
// 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;
@G9000
Copy link
Copy Markdown
Owner

G9000 commented Mar 15, 2026

@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>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 15, 2026

@copilot can you work on resolving the pr comments?

All PR review comments have been addressed in commit d73ed88:

  1. Dependency ordering — Created require_sqlite_mode FastAPI dependency (api/deps/db_mode.py) that runs before get_db in db.py, vault.py, and config.py routes. Also changed get_user_database_url to raise HTTPException(403) instead of RuntimeError, so even if something slips past the dependency, it's a clean 403 not a 500.

  2. Nonce no longer exposed via /health — Removed nonce from the health response entirely. Added a get_sidecar_nonce Tauri IPC command that delivers the nonce through a trusted channel. The frontend now calls invoke("get_sidecar_nonce") instead of fetching from /health.

  3. Middleware ordering comment — Fixed to accurately describe Starlette's reverse-add semantics (CORS is outermost, allowing OPTIONS preflights before nonce check).

  4. Nonce middleware tests — Added 4 new tests: missing header → 403, correct header → 200, wrong header → 403, health endpoints exempt from nonce. Also updated the health test to verify nonce is NOT exposed.

  5. Cross-platform CSPRNG — Replaced /dev/urandom + hash fallback with the getrandom crate for consistent cryptographic nonce generation on all platforms.

  6. JSON parsing in health check — Replaced brittle response.contains() with proper HTTP response parsing (split_once("\r\n\r\n")) and serde_json JSON parsing to check the status field.

  7. CSP connect-src — Added http://localhost:3031 alongside http://127.0.0.1:3031 for VITE_API_BASE_URL compatibility.

All 344 tests passing (327 original + 17 security hardening).

Co-authored-by: G9000 <11317652+G9000@users.noreply.github.com>
@G9000
Copy link
Copy Markdown
Owner

G9000 commented Mar 15, 2026

@copilot you address it all? Resolve conversation then so I know

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 15, 2026

@copilot you address it all? Resolve conversation then so I know

Yes — all 13 review comments are resolved. Here's the summary:

  1. Nonce no longer exposed via /health — delivered via Tauri IPC (get_sidecar_nonce command) instead
  2. Middleware ordering comment — accurately reflects Starlette's reverse-add semantics
  3. Nonce middleware enforcement tests — 4 new tests (missing/wrong/correct header + health exempt)
  4. Cross-platform CSPRNGgetrandom crate replaces /dev/urandom + hash fallback
  5. RuntimeErrorHTTPException(403) — clean fail-closed instead of 500
  6. Dependency orderingrequire_sqlite_mode dependency runs before get_db in db.py, vault.py, config.py
  7. CSP connect-src — both 127.0.0.1:3031 and localhost:3031 allowed
  8. Health check — JSON parsed with serde_json instead of brittle string matching

346/346 tests passing.

@G9000 G9000 merged commit 6d68adf into main Mar 15, 2026
1 check passed
@G9000 G9000 deleted the copilot/security-hardening-fastapi-tauri branch March 15, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants