Security Notes¶
Operational security guidance for JARVIS RD Assistant deployments.
Threat Model¶
Three Identities¶
JARVIS uses three distinct credential types with strictly bounded authority:
| Identity | Credential | Scope | Accesses user research data? |
|---|---|---|---|
| Ops / owner recovery | JARVIS_API_KEY (X-API-Key header) |
Service-to-service calls and a gated session exchange for the configured instance owner | Only after the owner exchange mints that administrator's normal scoped session; the bare key has no user identity |
| User | Magic-link session cookie (jarvis_session) |
Own data only — the session layer injects user_id into every request; current_user_id_strict enforces it |
Yes — own rows only |
| Admin | Session cookie with role = admin |
Manages users via /admin/users, views the audit log at /api/admin/audit-log; no access to other users' research data |
No cross-user data access — admin role controls ops, not data |
The JARVIS_API_KEY is an operations secret, not a family password. It can
mint a normal session for the configured owner on localhost or HTTPS, including
on a multi-user instance. It cannot mint sessions for other users. User-data
routes still require the identity carried by a session.
The session layer uses rolling (sliding) expiry (session_middleware.py). A signed-in session is valid for 30 days (SESSION_TTL); each time it is used its expiry rolls forward another 30 days, but at most once per day (SESSION_RENEW_AFTER) so a busy user triggers a single database write per day rather than one per request. Renewal is a single atomic UPDATE whose WHERE clause is the security boundary: it renews only rows that are not revoked and not already expired, so neither a revoked session nor a grace-resolved one can extend itself. When a renewal happens the response re-issues the Set-Cookie, so the browser and the database row move together and never disagree on when the session ends.
Layered on top is a deliberate 24-hour SESSION_GRACE window: a session expired by no more than 24 hours still resolves the user's identity (without renewing expires_at) so that reviews captured offline can reconcile after a realistic offline gap. This is an intentional offline-tolerance design choice, not a misconfiguration. revoked_at and deleted_at still hard-fail immediately regardless of the grace window — explicit revocation and account deletion are never relaxed. A scheduled purge job deletes expired-and-revoked sessions and expired passkey challenges so neither table grows without bound.
In-Scope Attackers¶
- Unauthenticated network-level attacker (open dashboard port, no session or key).
- Authenticated user attempting to access another user's data (IDOR, horizontal escalation).
- Admin attempting to read or mutate another user's research data.
- Attacker with write access to the
pulse_modelstable attempting RCE via pickle deserialization.
Out of Scope (acknowledged residual risks)¶
- Attacker with host-level OS access (Docker socket, filesystem, or env).
- Attacker with direct PostgreSQL superuser access.
- Physical access to the host.
- Supply-chain compromise of upstream images or Python packages.
See docs/known-residual-risks.md for the full residual-risk register.
Passkeys (WebAuthn)¶
JARVIS supports passkey sign-in and per-device credential management alongside the magic-link flow.
- Origin binding. A named WebAuthn origin is derived from the
operator-configured
APP_BASE_URLand matched exactly; the server never trusts a request-derived origin, and the RP ID is the matched hostname. A configured non-loopback origin must be HTTPS. Local installs additionally allow onlylocalhostandjarvis.localhost, on their served HTTP or HTTPS ports. - User verification (UV) is required for both registration and login. A login that fails UV is rejected without revoking the credential, so a failed verification cannot be used to lock a user out of their own passkey.
- Replay / one-finish. Each challenge is claimed with a single
DELETE … RETURNING, so a replayed or concurrently double-finished ceremony resolves exactly once. - Cloned-authenticator detection. An authenticator presenting a signature counter that is nonzero and less-than-or-equal-to the stored value is treated as cloned: the credential is revoked and all of its sessions are ended in the same transaction. Authenticators that legitimately never increment their counter (counter stays 0) continue to work.
- Revocation. Deleting one credential ends only that credential's sessions; an admin revoke-all ends every passkey session. Recovery requires working email, a manual link from a signed-in administrator, or the owner-only API-key exchange. Operators should verify one of those paths before removing the last administrator passkey.
- Availability signal. A capability endpoint reports whether the current
origin can run a passkey ceremony. Raw-IP LAN HTTP serves only
/health/jarvis; every other remote path returns HTTP 403. Remote users need a named HTTPS origin.
Source-aware paper visibility¶
Paper access is determined by the persisted visibility_scope and explicit
library membership. Provenance fields such as source_type and
discovered_by remain useful for audit and display, but they do not grant
access. An authenticated user can retrieve a paper only when it is persisted as
public or the user has explicitly added it to their own library.
| Origin | Persisted scope | Who can retrieve it? |
|---|---|---|
| Verified server source — arXiv, Semantic Scholar, OpenAlex, or PubMed through the server-owned adapter | Public | Every authenticated user on the instance |
| Local PDF upload | Private | Only users who explicitly add the paper to their library |
| Unverified client batch — including request-supplied citation metadata | Private | Only users who explicitly add the paper to their library |
| Personal or group Zotero import | Private | Only the connecting user unless another user independently adds the same paper |
| Ambiguous legacy Zotero identity | Private | Excluded from other users; it remains unlinked until a user explicitly shelves it or its namespace can be resolved safely |
| Unknown or unattributed provenance | Private | Excluded unless a user explicitly adds the paper to their library |
The same rule applies to feed results, direct paper reads, citations, graph views, summaries, and single- or cross-paper Ask. Full-text chunks and vectors do not widen it: authenticated vector retrieval also requires the current visibility generation, and the relational database rechecks every candidate. Missing or stale generation metadata therefore causes temporary under-fetch, never broader access.
Private activity and output remain user-scoped regardless of a paper's visibility: library membership, read state, ratings, notes, annotations, flashcards, projects and tasks, Pulse preferences, structured extractions, account settings, and sessions are never shared between users. Administrators manage the deployment but do not gain access to another user's private research through their role.
Dev Flags and Production Refusal¶
JARVIS has five granular development flags. When DEV_MODE=true, all five are
promoted to true unless overridden individually (see settings.py
_promote_dev_flags). None are permitted in ENVIRONMENT=production — the
service refuses to start if any is true at boot (validate_production_config
in auth.py).
| Flag | Effect when true (the protection it removes) |
|---|---|
DEV_AUTH_BYPASS |
All authentication is bypassed when no JARVIS_API_KEY is configured (no key and no session required). |
DEV_ERROR_DETAIL |
Raw exception detail is included in API error responses (information leakage). |
DEV_CORS_OPEN |
Access-Control-Allow-Origin is opened to * instead of the CORS_ORIGINS allowlist. |
DEV_SMTP_LOG_ONLY |
SMTP delivery is suppressed. Logs and the event stream receive only a SHA-256 recipient hash and non-secret status metadata; the address, bearer link, and token are never logged. |
DEV_CRYPTO_RELAXED |
Fernet key validation and HMAC key entropy requirements are relaxed. |
DEV_MODE=true is a meta-flag: it promotes any of the five that were not
explicitly set in the environment. An explicit env var always wins.
Secret Environment Variables¶
| Variable | Purpose | Required in production |
|---|---|---|
JARVIS_API_KEY |
Ops API key — gates all non-auth, non-health backend endpoints. Min 32 chars; enforced at startup. | Yes (startup refuses if absent or < 32 chars) |
JARVIS_CONFIG_KEY |
Fernet key for user_config.encrypted_value at-rest encryption. |
Yes (startup refuses if absent) |
JARVIS_MODEL_HMAC_KEY |
Dedicated HMAC-SHA256 key for Pulse classifier pickle signing (see below). Min 32 chars. | Yes (startup refuses if absent; derivation from JARVIS_API_KEY is refused in production) |
LITELLM_SALT_KEY |
Salt key LiteLLM uses to encrypt model credentials (cloud provider API keys) stored in its admin database. Auto-generated into secrets/litellm_salt_key.txt by scripts/init-secrets.sh; delivered as a Docker secret. Never rotate — LiteLLM decrypts stored credentials with this exact key, and without a dedicated salt LiteLLM falls back to the master key, so a master-key rotation would brick every encrypted row. |
Yes (the litellm container refuses to start without it) |
SMTP_HOST / SMTP_USER / SMTP_PASS |
SMTP relay credentials for automatic magic-link delivery. Without them, an administrator can still create a 24-hour invitation or 15-minute recovery link and share it privately. Explicit empty-string secret values are rejected; leave the fields unset when email is intentionally disabled. | Optional; recommended when users need self-service recovery |
SMTP_REPLY_TO |
Optional Reply-To address for sign-in emails. When set, email clients route replies here instead of the From address. Not a secret — this value appears in outgoing email headers. Configurable via the wizard or Settings → System → Email / SMTP. | No |
SMTP_FROM_NAME |
Optional sender display name shown in the From header (e.g. JARVIS RD). Not a secret — this value appears in outgoing email headers. Configurable via the wizard or Settings → System → Email / SMTP. |
No |
ALLOW_PRIVATE_SMTP_HOST |
Default false. When false, the SMTP host is validated at config-save AND at magic-link send time and rejected if it resolves to a private/loopback/link-local/reserved address (SSRF guard). Set true ONLY if you run a legitimate internal SMTP relay on a private address/hostname — otherwise magic-link delivery to that relay is refused. |
No (set only for an internal relay) |
Outbound Connection Pinning¶
Requests that leave the deployment — PDF downloads, custom model providers, Better BibTeX, SMTP, and LiteLLM custom-provider traffic — connect to the address that the guard above validated, instead of resolving the name a second time when the socket opens. This removes the window between the check and the connection, in which a name could resolve to an allowed address for the check and a different one for the request. Certificate and hostname verification are unchanged, so a pinned connection still presents and verifies the original hostname.
Cloud LLM Provider Settings at Rest¶
Cloud provider settings entered in Settings are deployment-wide admin settings.
Existing providers keep their backward-compatible flat keys (llm.anthropic.api_key,
llm.openai.api_key, llm.google.api_key); newer providers use namespaced keys
such as llm.providers.openrouter.api_key, llm.providers.deepseek.api_key,
llm.providers.mistral.api_key, llm.providers.moonshot.api_key,
llm.providers.zai.api_key, and llm.providers.custom_openai_compatible.api_key.
The custom OpenAI-compatible endpoint also stores
llm.providers.custom_openai_compatible.base_url.
Provider keys live in TWO encrypted stores when assigned to a role:
user_config.encrypted_value— Fernet-encrypted underJARVIS_CONFIG_KEY. This is the source of truth; it survives LiteLLM resets and is what the UI masks/reads back.- LiteLLM's admin database (
litellmDB in the bundled Postgres) — when a cloud model is assigned to a role, the key is decrypted in memory and carried in thePOST /model/newpayload; LiteLLM persists it inLiteLLM_ProxyModelTableencrypted underLITELLM_SALT_KEY.
Keys are never written to .env, litellm/config.yaml, container
environment blocks (visible via docker inspect), or any other file.
Rotating JARVIS_CONFIG_KEY (supported, see below) does not affect store 2;
LITELLM_SALT_KEY itself must never rotate (see the table above).
Custom endpoint base URLs are validated before save. The validator rejects credentials in the URL, fragments, invalid schemes, and unsafe IP ranges such as metadata-service, link-local, multicast, and unspecified addresses. HTTPS is allowed for normal remote endpoints; HTTP is limited to loopback development or self-hosted gateway use. Administrators should only configure endpoints they trust, because prompts and relevant source excerpts are sent there when a custom model is assigned.
Platform assertions and Telegram¶
Platform is the sole signer of short-lived, request-bound assertions. nginx obtains one for browser API traffic, and service principals receive only their declared capabilities. Telegram is a database-free REST client: it has no PostgreSQL or configuration-encryption credential and sends no generic impersonation header. Pairing and per-user authorization are resolved by the owning Platform or product API boundary.
Config Key Rotation¶
The standard Docker deployment rotates JARVIS_CONFIG_KEY during a short
maintenance window. The old and new keys are mounted into a one-off application
container as files; scripts/rotate_config_key.py first validates every row and
then re-encrypts them in one database transaction. Run
bash scripts/rotate-config-key.sh from the repository root: the wrapper updates
both the Docker secret file and .env, waits for healthy services, and can
resume safely after an interruption. Follow Encrypted config key
rotation; do not replace only one
of those two files by hand.
Database privilege boundary¶
The v1.2.6 database contract is tracked in
db/ownership-manifest.json. Platform,
Research, and Learning each have a NOLOGIN owner role and a distinct runtime
role. Operations has a NOLOGIN owner role; product runtimes reach only approved
job capabilities rather than receiving a general Operations writer role.
Runtime roles receive DML only in their own domain, selected cross-domain reads
or capabilities, and no DDL authority. PUBLIC does not receive application
schema creation or function execution by default.
The ownership contract is validated independently of production grants. A real-PostgreSQL test projects its table privileges inside a rollback transaction, grants owner and same-domain runtime access, and confirms that foreign-domain roles have no table privileges. The source inventory separately fails unclassified cross-domain writes and unreviewed computed SQL.
The physical platform, research, learning, and ops schemas are owned by
NOLOGIN roles. Platform, Research, Learning, and LiteLLM use distinct runtime
or migration identities. A one-shot migrator is the only normal migration
authority; runtime startup checks the schema floor and integrity read-only.
Proxy-Trust and Source-IP Allowlisting¶
Keep trusted_proxy_hosts scoped to the actual
reverse-proxy hop. Do not set trusted_proxy_hosts="*" in production. In the
standard stack, the application trusts only loopback and the dashboard
container's derived /32 address. Nginx, in turn, accepts forwarded HTTPS
metadata only on its separate trusted-ingress listener and only from the
derived host gateway, Caddy, local Caddy, and Cloudflare Tunnel addresses.
The public/LAN listener discards browser-supplied forwarding metadata.
Cloudflare client addresses are honoured only when the request arrives on the
trusted listener from the pinned cloudflared container and nginx marks that
ingress as Cloudflare-owned. Direct and LAN callers cannot opt in by sending
CF-Connecting-IP or X-Forwarded-For themselves.
| Boundary | Mapping | Remote behavior |
|---|---|---|
| Raw/local listener | container 3000, host 3001 |
Localhost gets the app; LAN clients get only /health/jarvis, with HTTP 403 everywhere else |
| Trusted-edge listener | container 3002 |
Only the derived gateway and pinned TLS edge containers are accepted |
| Tailscale host ingress | host loopback 127.0.0.1:3003 to container 3002 |
Tailscale Serve terminates HTTPS before forwarding |
These values are numeric because uvicorn matches the immediate socket peer
before applying X-Forwarded-For. Setup and update accept an IPv4 /27 or
larger network and derive the gateway and highest five usable addresses from
JARVIS_NET_SUBNET; a custom subnet does not require Compose or nginx edits.
Nginx defensively strips browser-supplied identity headers before proxying.
Identity is established by the Platform assertion boundary, not a client-set
header bypass. Override proxy defaults only for a topology whose exact peers
you control.
Transport header scope¶
The loopback mkcert edge sends Strict-Transport-Security: max-age=0 to clear
rather than enable HSTS, because browsers store that policy for the localhost
hostname across ports. A positive policy from port 3443 would break the supported
plain-HTTP fallback on port 3001. The public Caddy edge sets a one-year policy
for its configured hostname only; it does not claim sibling hostnames or browser
preload. Operators who make either broader commitment must first ensure that
every affected hostname already supports HTTPS.
Audit Log¶
Security-relevant events are written to the audit_log table by
libs/jarvis_common/jarvis_common/audit.py. The audit log is best-effort
(failures are logged but do not abort the request) and caps metadata payloads at
4 KB to prevent log inflation.
Events currently audited include: invalid API key attempts, missing session on user-data routes, magic-link request and verify, user creation and deletion, admin actions.
The audit log is readable by admins at GET /api/admin/audit-log
(cursor-paginated, admin session required).
Append-only invariant and GDPR erasure¶
audit_log is append-only: the no_update_audit_log and no_delete_audit_log
RULEs rewrite any ordinary UPDATE/DELETE into a no-op, so the operational
record cannot be tampered with after the fact. Because audit_log.user_id is a
free-text column (not a foreign key), hard-deleting a user does not cascade to
it. To honour GDPR erasure, the daily purge (jobs/data_purge.py) anonymizes a
hard-deleted user's audit rows — nulling user_id and stripping PII metadata
keys (e.g. ip) while keeping the non-identifying operational record (action,
resource, timestamp). This is the one sanctioned mutation: it brackets the
UPDATE with ALTER TABLE … DISABLE/ENABLE RULE no_update_audit_log in a single
transaction (RULEs are query-rewrite and role-independent, so SECURITY DEFINER
cannot bypass them); the rule is re-enabled before commit, so ordinary writes
remain no-ops.
The v1.2.6 erasure contract removes that runtime DDL exception. Platform first disables the account and records an idempotent erasure request. Research must durably acknowledge Qdrant deletion or redaction and a residual scan showing zero attributable points; Research and Learning must also acknowledge their owner-local cleanup. Only then may an isolated execute-only principal invoke audited, DML-only finalization procedures. Qdrant unavailability, residual points, or a domain cleanup failure leaves the account disabled in a visible, retryable state; retry exhaustion requires operator attention rather than silently deleting relational data first.
A completed erasure also removes the papers the account was the only holder of, together with the text extracted from them and the documents stored on disk, so that nothing an erased account contributed alone survives as retrievable content. Three kinds of paper are deliberately kept: one another researcher still holds in their library, which is that researcher's data and not the departing account's; one the deployment publishes to everybody; and one another account has already asked to discard, whose own cleanup has to finish before the paper can go. The rule is re-checked at the moment of deletion rather than taken from the earlier read, and a paper's stored document is reclaimed only once its record is actually gone, so a paper that turns out to be spoken for keeps the document it still needs. Removal runs before the relational cleanup, because library membership is what identifies those papers.
Immutable audit events must not retain directly erasable identity or free-form personal data. The schema cutover moves that link and erasable metadata into a mutable Platform-owned subject mapping, sanitizes historical rows once through the migrator, and limits runtime erasure to deleting or anonymizing the mapping. The compatibility implementation above remains in effect until that migration and DML-only path are installed.
Vulnerability Disclosure¶
Reporting a vulnerability is documented in the repository's top-level
SECURITY.md
policy (private GitHub Security Advisories;
acknowledgement within 5 business days). Do not open public GitHub issues for
security reports.
Pulse Model Signing¶
The Pulse classifier persists a serialized scikit-learn LogisticRegression
model into the pulse_models table as an HMAC-signed pickle blob. Verification
happens in services/paper_ingestion/paper_ingestion/pulse/training.py::_verify_and_unpickle
before pickle.loads is called — without the HMAC gate, anyone with DB write
access could forge a blob and trigger RCE.
Configuration¶
The HMAC key is resolved at call time:
JARVIS_MODEL_HMAC_KEY— a dedicated secret used solely for signing model blobs. Generate withopenssl rand -hex 32. Keeping this separate fromJARVIS_API_KEYmeans a compromise of the HTTP bearer does not also let an attacker forge model blobs, and vice versa. This dedicated key is mandatory in production and in every multi-user deployment.- Derived from
JARVIS_API_KEY— a backward-compatible fallback allowed only in single-user, non-production mode. The signing key issha256(b"model-signing:" + JARVIS_API_KEY); the prefix domain-separates it from direct use of the bearer.
If the allowed key is absent, _hmac_key() raises RuntimeError. Production
startup and the multi-user gate refuse a missing or short dedicated key; they
never accept the derived fallback.
Key Rotation¶
There is no in-place rotation framework. To rotate the HMAC key:
- Update
JARVIS_MODEL_HMAC_KEYand restart the affected services. - Existing
pulse_modelsrows will fail HMAC verification and the scoring path falls back to zeros until a new model is trained. - The nightly
pulse.train_classifiercron job re-trains automatically. To force an immediate re-train, enqueuepulse.train_classifiervia the jobs API.
Ollama Security Posture¶
The Ollama daemon handles local LLM inference. Key constraints:
- Image pin: keep
OLLAMA_IMAGEinversions.envat the tested pin (ollama/ollama:0.31.2) or a newer validated pin. Downgrading below the patched line reintroduces known vulnerabilities (see known-residual-risks.md for the current CVE posture entry). - Host binding: the default Compose configuration binds the Ollama host
port to loopback only (
127.0.0.1), preventing browser and LAN clients from calling the daemon directly. - Docker network boundary: every container on the
jarvisDocker network can reachhttp://ollama:11434. Do not attach untrusted sidecars to that network. - Shared daemon override: if the operator configures an external shared Ollama daemon instead of the bundled one, that daemon must be patched to an equivalent or newer pin and bound to loopback or an equivalently trusted private network.
Review this posture whenever OLLAMA_IMAGE is updated or the Compose network
topology changes.
Restore boundary¶
The application does not perform database restores. An active administrator
must type RESTORE; the app then writes a request to the backup_trigger
volume. That request remains inert until an operator runs
jarvis-research restore run on the host. The command starts a profile-gated,
transient restore job that takes the safety backup, verifies the selected
restore point, loads staging databases, and performs the swap. Dedicated
bootstrap and migration jobs then reconstruct database authority and advance
the restored schema before maintenance can end. The continuously running
postgres-backup service has read-only database authority, cannot resolve the
restore credential, and cannot execute a restore. The app mounts backup
archives read-only, and the operations API key cannot start a restore.
A failure before the swap leaves the live databases in place. Once the swap begins, a failure keeps the service in maintenance until the operator recovers from the safety restore point. Restoring a point from a newer application version is refused.
A compromised admin session can queue a destructive rollback but cannot execute it without host access. Typed confirmation, host execution, and the safety point reduce accidents; host compromise remains outside that protection. See the risk register.
Fresh-host uploads¶
Browser uploads go to the dedicated restore-uploader container, not the
application. It can write only to restore_inbox and can read only the hashed,
expiring upload grant from backup_trigger. It has no database credentials,
Docker socket, or host-secret mount. A restore still requires both the separate
admin confirmation and the host command.
The scheduled backup service publishes only a bounded inbox inventory. The
transient restore job checks the signed archive set and proves that the supplied
key can decrypt it before the swap. It removes the one-time key and plaintext
staging on normal and recorded-failure exits; a forced SIGKILL can delay that
cleanup until the next recovery run. Same-host restores do not use the inbox key. The
backup and restore guide contains both browser
and headless recovery procedures.