title: Review the security risks
description: STRIDE-by-component threats and mitigations for LQ.AI's five production services — start here, then compare it with how your own installation is configured.
url: https://legalquants.github.io/lq-ai/trust/threat-model/
checked-against: 671eab0d (2026-10-25)
status: draft
kind: reference
sources: docs/security/threat-model.md, docs/security/boundary-registers.md

> **Depth:** Summary-level. STRIDE-by-component for the 5 production services. Named threats + named mitigations with cross-references. Detailed design intent lives in PRD §5 and the ADR series.

## In short

- This page is LQ.AI's threat model: a STRIDE-by-component table covering each of the five production services (`api`, `gateway`, `web`, `postgres`, `rustfs`). "STRIDE" is impersonation, unauthorized changes, missing evidence, disclosure, service disruption, and gaining higher privileges — read the table cell-by-cell for the service and category you're checking.
- The Inference Gateway, holding the only plaintext provider API keys, is the primary trust boundary — see "Trust boundaries" below for the three trust zones.
- Most mitigations cite the code, ADR, or PRD section behind them, so you can usually verify the claim yourself rather than take it on trust.
- Host administration, OS patching, and secret storage are out of scope here and stay the operator's responsibility — a compromised host isn't something this table, or an application setting, fixes.

## Trust boundaries

LQ.AI runs as 7 services on a single operator-controlled deployment (Docker Compose for dev; Helm/Kubernetes for production per `deploy/helm/lq-ai/`). The Inference Gateway is the only component holding plaintext provider API keys per PRD §4; this defines the primary trust boundary. Everything internal to the operator's deployment is one trust zone; the LLM providers (Anthropic, OpenAI, etc.) are another; the operator's IdP (if integrated) is a third.

```mermaid
flowchart LR
    accTitle: Trust boundaries in an LQ.AI deployment
    accDescr: The services sit inside one operator deployment; the model providers sit outside it, a separate trust zone. Requests run from web to api to gateway, and only the gateway calls out to the providers; api also connects to postgres, redis and rustfs, and the gateway writes its routing log to postgres.
    subgraph deployment["Operator deployment"]
        direction LR
        web["web"] --> api["api"] --> gateway["gateway"]
        api --> postgres["postgres"]
        api --> redis["redis"]
        api --> rustfs["rustfs"]
        gateway --> postgres
    end
    gateway --> providers["providers"]
```

The five rows in the STRIDE table below cover the production-facing services (`api`, `gateway`, `web`, `postgres`, `rustfs`). Redis and the ingest-worker are cluster-internal and inherit `postgres`-tier mitigations (least-privilege role, no external listener, operator-managed secret); they do not add a distinct row.

### Untrusted document content → model instruction channel

The boundary the STRIDE table below does not draw is the one between the operator's instructions to the model and the *content the model is asked to work on*. A counterparty's NDA, a retrieved knowledge-base chunk, an attached file, its filename, and a tool result are all attacker-influenced bytes, and the product's own stated threat (PRD §1.9, Appendix E) is a malicious counterparty document carrying instructions. Skill inputs, retrieved chunks, attachments, and filenames are placed in the `role="system"` message — the instruction channel — by the gateway's skill assembler (`gateway/app/skills/assembler.py`, caller-supplied skill inputs) and by the chat path (`api/app/api/chats.py`, retrieved chunks and attachments, verbatim and anonymization-skipped so citation grounding stays byte-exact). Chat tool results already use JSON-encoded `role="tool"` messages (`api/app/chat/tool_loop.py::tool_result_message`); that placement still requires adversarial evaluation and does not establish immunity to prompt injection.

**Threat (Tampering / Elevation of Privilege at the model boundary):** instructions embedded in untrusted content steer the analysis presented as skill output, influence tool selection, or impersonate the operator's own instruction block. **Mitigations shipped:** unconsumed skill inputs are JSON-encoded on one line into a source-labelled block under an explicit data-not-instructions policy, so a value cannot start a new prompt line, close a fence, or reproduce the assembler's `## Operator system instructions` / `# Skill:` markers (ADR 0007, amendment of 2026-09-02). Placeholder-consumed inputs remain in the skill text; input-created assembler headings are backslash-escaped, including headings formed across template boundaries. Tool calls require a persisted, human-confirmed lifecycle state before they execute; the Citation Engine's verifier rejects quotes that do not match the source byte-for-byte; and the operator's human-in-the-loop review is the last line. **Residual risk, stated plainly:** the envelope preserves structure but does not reduce the authority of those bytes — a model can still follow well-formed instructions it reads under an "untrusted" label in the system message, and the retrieval / attachment blocks have not received even the envelope treatment. Relocating the remaining system-message content into a data channel the model is told to distrust is [DE-388](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/PRD.md#9-deferred-enhancements-and-identified-future-work), ADR-gated, and depends on an adversarial evaluation corpus (DE-110) that does not exist yet. There are no "skill-prompt isolation conventions" in the skill-authoring guide; a previous version of Appendix E said there were.

## STRIDE-by-component

**Local account recovery.** Trusted admins can provision accounts or reset any live
account, including admins. Password responses carry `Cache-Control: no-store`; only
bcrypt hashes are stored and audit details exclude passwords. A password reset or
change increments the user's credential version and revokes refresh sessions atomically.
Access tokens and MFA challenges issued under an earlier version are rejected on later
requests. Token issuance and password mutation serialize on the user row, and refresh
rechecks revocation after taking the lock. Existing MFA enrollment is preserved.
Already authorized requests/streams are not interrupted. See [ADR 0002](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/adr/0002-backend-owned-auth.md)
and [token lifecycle](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/security/cryptography.md#per-user-credential-invalidation).

| Component | Spoofing | Tampering | Repudiation | Info Disclosure | DoS | Elevation of Privilege |
|---|---|---|---|---|---|---|
| **api** | **Threat:** Forged or replayed JWT (e.g., obtained from leaked logs, MITM on a misconfigured reverse proxy, or developer-machine theft) lets an attacker impersonate the legitimate user. **Mitigation:** JWTs are HS256-signed against `JWT_SECRET` (ADR 0002); access tokens expire in ~15 min, refresh tokens rotate on use and are hashed at rest in `user_sessions`, and `must_change_password` plus admin-driven session revocation provide server-side invalidation. | **Threat:** Tampered request bodies (oversized fields, type confusion, mass-assignment) attempt to mutate fields the caller should not control. **Mitigation:** Every endpoint validates the request body through Pydantic models generated from `docs/api/backend-openapi.yaml`, and all mutating handlers re-derive `owner_id` from the authenticated principal rather than trusting client input. | **Threat:** A user denies having deleted a matter, exported a chat, or changed a tier-config; an admin denies tampering with the audit log itself. **Mitigation:** Every state-changing call writes an `audit_log` row with actor, action, before/after hashes, IP, user-agent, `privilege_marked`, and `routed_inference_tier` (PRD §5.3); admin actions on the audit log itself are flagged `privilege_marked` and cannot be silently dropped. | **Threat:** Stolen access token (XSS exfil from `web`, shoulder-surfed bearer header, leaked debug log) grants the bearer the user's data window. **Mitigation:** Short JWT TTL bounds the window; `RateLimited` (`api/app/errors.py`) gates the login surface; refresh tokens are hashed in `user_sessions` so DB read alone cannot resurrect a session; per-user data export (PRD §5.3) is RBAC-gated and audit-logged. | **Threat:** Unauthenticated request flood (login brute-force, expensive endpoints like file upload) starves the service. **Mitigation:** Authentication required on every non-public route via FastAPI dependency injection; login is edge-rate-limited (`api/app/security/passwords.py`); body-size and content-type ceilings enforced before handler dispatch. | **Threat:** A Member or Viewer reads or mutates another tenant's data (chats, files, projects) by guessing resource IDs. **Mitigation:** Every query is scoped `WHERE owner_id = current_user.id` (see `api/app/api/files.py`, `chats.py`, `projects.py`); admin/member/viewer roles are an enum enforced via FastAPI dependencies (PRD §5.2); ADR 0005 keeps tenancy at the application layer rather than the storage key. |
| **gateway** | **Threat:** Forged inference request from inside the deployment network attempts to bypass the api and call a provider directly (skipping tier-floor + audit). **Mitigation:** Every gateway route requires the `X-LQ-AI-Gateway-Key` shared secret compared via `hmac.compare_digest` (`gateway/app/api/dependencies.py`); the key lives only in the gateway's and api's process environment. | **Threat:** A compromised admin path (or a malicious operator with config write access) tampers with `gateway.yaml` to silently re-route requests to an attacker-controlled provider. **Mitigation:** Atomic temp-file + `os.replace` writes with Pydantic validation on reload reject malformed configs without flipping the holder (ADR 0010); hardened deployments mount `gateway.yaml` read-only and forfeit the admin write affordance; alias edits are audit-logged at the api boundary. | **Threat:** Disputes about which provider/model served a privileged request (e.g., a Tier-3 commitment was actually fulfilled by a Tier-4 fallback). **Mitigation:** Every call writes an `inference_routing_log` row (`api/app/models/inference.py`) with `routed_provider`, `routed_model`, and `routed_inference_tier`; the same fields are persisted on the `Message` row and surfaced in the chat UI per ADR 0011, so the user-visible answer matches the audit record. | **Threat:** Provider API keys exfiltrated from `gateway.yaml`, environment listing, or a backup snapshot. **Mitigation:** ADR 0011 + `docs/security/encrypted-keys.md` ship the Fernet-wrapped `api_key_encrypted` path; plaintext exists only in `GatewayConfig.providers[*].api_key` in heap for the duration of the adapter call and is never logged, serialized, or returned by the admin API. | **Threat:** Token-cost amplification — an attacker (or runaway client) drives expensive provider calls to exhaust the operator's spend cap. **Mitigation:** PRD §5.5 cost tracking tags every request with user/capability/chat; per-tier and per-user quotas attach at the gateway router; the api enforces tier-floor refusal (D1) so a forged "downgrade" cannot land. | **Threat:** Tier escalation — a request asks for a Tier-1 model on a Tier-3-floored skill, or vice versa, to leak privileged content to a lower-assurance provider. **Mitigation:** Tier-floor enforcement runs server-side in the gateway (D1, ADR 0011); the resolved tier on every alias is published to the picker so users cannot silently land below a skill's `minimum_inference_tier`; admin alias edits do not bypass the floor check. |
| **web** | **Threat:** Session theft via XSS, malicious browser extension, or stolen `localStorage` lets an attacker reuse the LQ.AI shell's tokens. **Mitigation:** Tokens live under dedicated `lq_ai_access_token` / `lq_ai_refresh_token` keys distinct from OpenWebUI's (ADR 0009); the LQ.AI shell calls the api over HTTPS with `Bearer` headers (not cookies, so the surface is XSS-bound rather than CSRF-bound); the api's short TTL + refresh rotation bounds the stolen-token window. | **Threat:** XSS via skill markdown, model output, or chat content tampering the DOM and exfiltrating tokens. **Mitigation:** `MessageBubble.svelte` renders assistant content through `marked` + `DOMPurify.sanitize`; SvelteKit's default escaping bounds non-message paths; the OpenWebUI shell at `/` is intentionally isolated from the LQ.AI shell's state per ADR 0009. | n/a (web does not author audit events; every state-changing action is the api's audit row, which records actor, IP, and user-agent regardless of which shell originated it). | **Threat:** Disclosure of cached skill/file content from `localStorage` or service-worker storage when a shared device is repurposed. **Mitigation:** No provider keys, JWTs of other users, or raw file bytes are ever written to `localStorage`; the LQ.AI client clears its token pair on logout and on the 401-then-refresh-fail path (ADR 0009 §"Token refresh"); operators with stronger requirements front the deployment with an idle-timeout reverse proxy. | **Threat:** Client-side render bomb (multi-megabyte assistant message, runaway markdown nesting) freezes the browser tab. **Mitigation:** The api enforces message-content size ceilings before persistence; the streaming SSE parser (`web/src/lib/lq-ai/sse/parser.ts`) emits bounded `MessageDelta` frames and the consumer caps in-flight chunks. | n/a (the browser sandbox bounds privilege escalation to the user's own session; cross-origin isolation between the LQ.AI shell and the OpenWebUI shell follows the standard same-origin model per ADR 0009). |
| **postgres** | n/a (cluster-internal; not exposed outside the operator's deployment network — same posture as the Compose/Helm reference topologies in PRD §2.4). | **Threat:** Row tampering by a co-located service with broader write rights than it needs, or by an attacker who has pivoted into the deployment network. **Mitigation:** The api connects as a non-superuser application role per PRD §5.6; pgcrypto column encryption protects the audit-log payload and `api_key_encrypted`-shaped fields; backups are operator-encrypted at the infrastructure layer. | n/a (Postgres server logs are operator-managed and outside the application's audit trail; the application's repudiation story lives in the `audit_log` table itself). | **Threat:** Cross-tenant disclosure via `pgvector` similarity search returning embeddings whose document the requesting user does not own. **Mitigation:** Every vector query joins through the `files`/`documents` tables and re-applies `owner_id`-scoped predicates (the same posture as the per-handler `WHERE owner_id` filter in ADR 0005); embeddings without an owner row are unreachable from the api. | **Threat:** Connection exhaustion (leaked sessions, runaway report query) starves all callers. **Mitigation:** SQLAlchemy connection pool ceiling in `api/app/config.py`; `/health` and `/ready` probes surface saturation; Helm chart sets resource requests/limits on the api Deployment so a single replica's pool exhaustion does not propagate. | **Threat:** Superuser escalation if the application role is over-privileged or shares credentials with the bootstrap role. **Mitigation:** Operators deploy with a dedicated, non-superuser app role (the Helm chart's default); Alembic migrations run as a separate higher-privileged role only during the deployment's migration job, not at request time. |
| **rustfs** | n/a (cluster-internal; the api is the only client of the object store per ADR 0005, so external spoofing reduces to spoofing the api). | **Threat:** Object tampering — a file's bytes are replaced after upload to subvert downstream skill/Citation-Engine output. **Mitigation:** Every upload records SHA-256 in `files.hash_sha256`; migration verification re-reads each document and compares that digest, and soft-delete preserves the original bytes for audit lookup (ADRs 0005, 0036, 0037). | **Threat:** Disputes about who uploaded what bytes. **Mitigation:** Every upload writes an `audit_log` row with actor, file UUID and SHA-256; the append-only deployment journal separately records migration phases and snapshot digests without content. | **Threat:** Cross-tenant object access through leaked root credentials or a `storage_path` bug. **Mitigation:** The api enforces owner scope before any content read; root credentials stay in the operator-managed Secret; the S3 endpoint and console bind to loopback in Compose and remain cluster-internal in Helm. | **Threat:** Storage exhaustion through uploads, retained soft-deletes, or migration snapshots. **Mitigation:** Upload ceilings and the hard-delete path bound application growth; `migrate plan` refuses unless snapshot free space is at least 110% of the volume; snapshots are listed for explicit operator retention decisions. | **Threat:** RustFS admin escalation or an ownership-confused volume bypasses api scoping or quietly makes storage unavailable. **Mitigation:** The store runs as uid/gid 10001, the init gate fixes ownership only after a snapshot-backed marker, `/health/ready` (not liveness) gates dependants, and the api keeps a generic S3-only contract (ADR 0036). |

## Out-of-scope threats

- **Compromise of the operator's host / OS / supervisor.** LQ.AI assumes the operator's deployment substrate is trusted. Container escape, OS-level compromise, and physical access are operator responsibilities.
- **Compromise of an LLM provider.** Gateway minimizes blast radius (Anonymization, Tier Derivation, per-provider isolation), but a compromised provider that already received unredacted content is outside our trust envelope.
- **Compromise of operator-controlled secrets.** If `JWT_SECRET`, `LQ_AI_GATEWAY_KEY`, or `LQ_AI_GATEWAY_MASTER_KEY` leaks, an attacker can forge tokens, spoof internal calls, or recover provider keys. Operators rotate per their own runbooks; the gateway-config-hot-reload path (ADR 0010) and the master-key rotation procedure in `docs/security/encrypted-keys.md` support rotation without invasive downtime.

## Cross-references

- [PRD §5 Security & Compliance](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/PRD.md#5-cross-cutting-concerns) — design intent.
- [ADR 0002 Backend-owned auth](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/adr/0002-backend-owned-auth.md) — JWT scheme + session model.
- [ADR 0005 File storage soft-delete + key scheme](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/adr/0005-file-storage-soft-delete-and-key-scheme.md) — object key tenancy.
- [ADR 0009 Web + LQ.AI shell coexistence](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/adr/0009-web-lq-ai-shell-coexistence.md) — frontend trust boundary.
- [ADR 0010 Gateway config hot-reload](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/adr/0010-gateway-config-hot-reload.md) — secret rotation.
- [ADR 0011 Transparency-first model selection](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/adr/0011-transparency-first-model-selection.md) — encrypted-at-rest provider keys.
- [ADR 0007 Skill prompt assembly](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/adr/0007-skill-prompt-assembly.md) — the untrusted-input envelope (amendment of 2026-09-02) and what it does not do.
- [Encrypted-at-rest provider keys workflow](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/security/encrypted-keys.md) — master-key bootstrap and rotation.
- [Vulnerability disclosure policy](/lq-ai/trust/security-disclosure/) — coordinated disclosure process.
- [Boundary registers](https://github.com/LegalQuants/lq-ai/blob/671eab0d/docs/security/boundary-registers.md) — a separate, orthogonal catalog that restrains *autonomous work* (economic, temporal, contextual limits, and how each is enforced) rather than the STRIDE threats above, which cover the five production services as they exist today.

## Update cadence

This threat model is refreshed each minor release when a new component lands or a trust boundary moves. The current version covers the api, gateway, web, postgres, redis and RustFS object store. Future cycles add the Word add-in and Compliance Alignment Pack mappings.

## Next

- [What information is sent outside LQ.AI?](/lq-ai/trust/what-leaves-my-deployment/)
- [Replace identifying details before sending a request](/lq-ai/trust/anonymization/)
- [What the audit log records](/lq-ai/trust/audit-and-evidence/)
