6.1 KiB
Threat Model
Odysseus is a self-hosted AI workspace with privileged local access. This document states the trust boundary so contributors can reason about security decisions without reading through the full auth and middleware stack.
Trust Boundary
Odysseus is designed for trusted users on a private network, not public exposure. The README describes it as "treat it like an admin console" — that framing is accurate. A logged-in admin can execute shell commands, read and write files, send email, and control model serving. This is intentional. The threat model does not try to prevent admins from doing these things. It does try to prevent:
- Unauthenticated access
- Non-admins reaching admin-only capabilities
- The AI agent acting on instructions injected through untrusted content (web results, emails, fetched pages, memories)
- Internal services (ChromaDB, Ollama, SearXNG, etc.) being reachable from outside the host
Roles and Capabilities
| Capability | Admin | Non-admin (default) |
|---|---|---|
| Chat with agent | ✓ | ✓ |
| Browser tool | ✓ | ✓ |
| Documents | ✓ | ✓ |
| Research mode | ✓ | ✓ |
| Image generation | ✓ | ✓ |
| Memory management | ✓ | ✓ |
| Shell / Python execution | ✓ | ✗ |
| File read / write | ✓ | ✗ |
| Email send / read | ✓ | ✗ |
| MCP tools | ✓ | ✗ |
| Calendar management | ✓ | ✗ |
| Token / webhook management | ✓ | ✗ |
| Model serving | ✓ | ✗ |
| Vault | ✓ | ✗ |
| Settings | ✓ | ✗ |
Non-admin defaults are in core/auth.py:DEFAULT_PRIVILEGES. Tool enforcement is in src/tool_security.py:NON_ADMIN_BLOCKED_TOOLS. Any tool whose name starts with mcp__ is also blocked for non-admins. Admins always get full access regardless of stored privilege values.
Authentication
- Sessions: bcrypt passwords, 7-day session tokens stored atomically in
data/sessions.jsonviacore/atomic_io.py. - 2FA: TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
- Reserved usernames:
internal-tool,api,demo,systemcannot be registered or renamed into. Defined incore/auth.py:RESERVED_USERNAMES.internal-toolis security-critical:core/middleware.py:require_admintreats any request whererequest.state.current_user == "internal-tool"as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass everyrequire_admincheck.
- Orphan sessions:
validate_tokenre-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
Internal Tool Loopback
Agent tool calls reach admin-gated HTTP routes over an in-process HTTP loopback. The mechanism:
- At app startup,
core/middleware.pygenerates a randomINTERNAL_TOOL_TOKENviasecrets.token_hex(32). It is never persisted and never sent to clients. - Loopback requests carry
X-Odysseus-Internal-Token: <token>or haverequest.state.current_useralready set to"internal-tool"by the auth middleware. require_adminrecognises either signal and grants access without checking the session user.
The agent may be running in a non-admin user's session, but tool dispatch first calls src/tool_security.py:owner_is_admin_or_single_user to verify the session owner is an admin before issuing any loopback call. Non-admin users cannot invoke admin tools even via the agent.
Prompt-Injection Hardening
External content that reaches the LLM is treated as untrusted via src/prompt_security.py:
untrusted_context_message(label, content)wraps the content in auser-role message with a header block instructing the model not to follow instructions inside it. Content goes in as data, not as a system instruction.UNTRUSTED_CONTEXT_POLICYis a system-prompt preamble that states the same policy at the top of every session where untrusted data may appear.
Untrusted surfaces that must go through this wrapper: web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug.
Security Headers
core/middleware.py:SecurityHeadersMiddleware sets headers on every response:
X-Frame-Options: DENY+frame-ancestors 'none'on all routes except tool-render iframes (which are sandboxed at the HTML level).X-Content-Type-Options: nosniffandReferrer-Policy: no-referrereverywhere.- CSP: nonce-based
script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net.style-src 'unsafe-inline'is intentionally kept —static/index.htmlships inline<style>blocks and JS modules setstyle=""attributes at runtime. Inline styles do not execute script so the risk is visual-only. Removing this requires templating the HTML files and auditing all JS-set style attributes.
Known Gaps
These are open, acknowledged, and contributor help is welcome:
-
No shell/filesystem sandbox. The agent
bashandread_file/write_filetools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. -
SSRF via
/api/v1/chatbase_urlparameter. A chat-scoped API token can supply an arbitrarybase_url; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this. -
src/search/partial consolidation.src.search.coreandsrc.search.providerscorrectly aliasservices.searchviasys.modulesreplacement.analytics,cache,content,query, andrankingare still independent copies that can drift. The SSRF regression tests intests/test_webhook_ssrf_resilience.pytestsrc.webhook_managerdirectly (separate from search), so the safety net there is intact. See #1058. -
Token scopes are coarse. There is no way to grant a session a subset of the owning user's privileges. Companion/mobile tokens carry either
chatoradminscope with no per-capability granularity.