Skip to content

Review the security risks

Reference
  • evaluator
  • operator

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.

  • 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.

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.

Trust boundaries in an LQ.AI deployment
Trust boundaries in an LQ.AI deployment Trust boundaries in an LQ.AI deployment

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.

Open full-size diagramOpen full-size diagram
Diagram source
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

Section titled “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, 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.

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 and token lifecycle.

api

Spoofing
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.
Tampering
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.
Repudiation
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.
Info Disclosure
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.
DoS
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.
Elevation of Privilege
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

Spoofing
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.
Tampering
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.
Repudiation
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.
Info Disclosure
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.
DoS
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.
Elevation of Privilege
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

Spoofing
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.
Tampering
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.
Repudiation
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).
Info Disclosure
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.
DoS
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.
Elevation of Privilege
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

Spoofing
n/a (cluster-internal; not exposed outside the operator’s deployment network — same posture as the Compose/Helm reference topologies in PRD §2.4).
Tampering
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.
Repudiation
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).
Info Disclosure
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.
DoS
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.
Elevation of Privilege
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

Spoofing
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).
Tampering
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).
Repudiation
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.
Info Disclosure
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.
DoS
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.
Elevation of Privilege
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).
  • 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.

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.