If you discover a security vulnerability, please report it responsibly:
- Do NOT open a public GitHub issue
- Use GitHub Security Advisories to report privately
- Or contact: https://github.com/JFK
We aim to acknowledge reports within 48 hours and provide a fix within 7 days for critical issues.
| Version | Supported |
|---|---|
| Latest release | ✅ |
| Previous minor | ✅ (security fixes only) |
| Older | ❌ |
-
OAuth2 (Google, GitHub) for user login
-
API Keys for programmatic access (SHA-256 hashed, Fernet encrypted at rest)
-
JWT for session tokens (configurable expiry, HS256)
-
HttpOnly cookies for session storage
-
Linking a sign-in provider (a Google / GitHub identity added to an account from the profile page) completes only in the browser session that started it: the provider callback must carry that session's cookie, and the session must still hold the account. Otherwise the callback refuses (
/profile?error=link_failed) and attaches nothing, so the authorization URL the page is sent to is of no use to any other browser. During an upgrade, a link started on a release without this check fails the same way for at most the OAuth state lifetime (5 minutes); starting it again works. -
Email + password for existing accounts. A password reset — by emailed link, or by an operator with the
reset_passwordCLI (choices 1 and 3) — signs out every browser session and revokes every OAuth / MCP token and pending grant (authorization code, device code) of the account. The grant revocations are in the database transaction of the new password; the sessions live in Redis and are deleted just before that transaction commits, so the password is never committed while old sessions survive (a commit that then fails leaves the old password with the sessions already signed out; the CLI says so and the reset is run again). A consent or device approval racing the reset is refused rather than left behind as a grant the reset could not see. API keys and OAuth client secrets are not revoked automatically — review them in Settings after a suspected compromise. The two paths differ in three ways:- the CLI reset writes no audit row and sends no notice (see below);
- the CLI must reach the session store (Redis,
REDIS_URL): it refuses to reset the password when the store is unreachable, and rolls the reset back and exits non-zero when deleting the sessions or the commit fails, so it never reports success while old sessions survive. Its messages name the store's host and port and the error type only, never the URL's credentials or a statement's parameters; - the CLI's "Disable MFA only" (choice 2) changes no credential and revokes nothing — sessions, tokens and known devices are kept. After a suspected compromise, reset the password (choice 1 or 3).
-
Security-change notification emails — the account owner is emailed (mandatory, no opt-out) when:
- a password is set, changed or reset with an emailed link;
- a sign-in method is added (a Google / GitHub identity linked) or removed (the password, or an unlinked identity);
- an OAuth / MCP client is authorized: by browser consent when it grants the client something new — the first authorization, a scope the user has not granted it before, or a client whose name, redirect URIs or scope changed since the user's last grant (an unchanged repeat consent sends nothing: the code only reaches the client's registered redirect URI) — and by device-flow approval every time (a device code can be phished — an attacker starts the flow and gets the victim to approve it);
- an API key is created or regenerated (including a connector's write key), an OAuth client is registered or its secret regenerated — also when a workspace admin or owner does it, whom the email then names;
- a provider sign-in changes the account's email address: the previous address is told, and the notices still pending go to it;
- a browser signs in (password, with or without MFA, Google or GitHub)
from a device the account has not signed in from before. The browser is
recognized by a long-lived HttpOnly device cookie; only the keyed HMAC of
its value is stored, with first/last sign-in times — not the IP address
or user agent. Those go into the email and, like every notice's details,
sit in the notice queue (Redis) only while a notice is coalesced or
retried, at most about eight days. The user agent is not part of the
match — it is spoofable. An account's first browser
sign-in ever registers the device silently (so the device the account
was created from sends nothing); from then on the account stays armed. A
password reset (emailed link or CLI) forgets every known device but not
that the account is armed, so the next sign-in from each browser — the attacker's included,
even when it comes first — is reported. Devices not seen for
KNOWN_DEVICE_RETENTION_DAYS(default 180, at most 365) are forgotten by a daily job and reported again on their next sign-in, at mostKNOWN_DEVICE_MAX_PER_USER(default 20) are kept per account, and account erasure deletes them. An authenticated password change keeps the known devices: it already signs the other browsers out, and forgetting them would report every one of the owner's own browsers again. CLI / MCP sign-ins (device flow, token endpoint) carry no cookie and are not covered; - a password sign-in with MFA fails the second factor until the account's sign-in budget (5 failures per 5 minutes, shared by the password and the TOTP step) is spent: whoever sent the codes had the password. Wrong passwords alone send nothing — anyone who knows a login ID can send them.
Each email lists the UTC time, IP address, user agent and the key or client name. A notice about a change a workspace administrator made to a member's credentials names the administrator instead and leaves out the IP address and user agent, which are the administrator's. It never carries a secret, token, key value or action link; the "Wasn't you?" paragraph points to the pages that list sign-in methods, API keys and OAuth apps and to "Forgot password?". Untrusted names and user agents are normalized and defanged so no link survives. The first three occurrences of one event within the coalescing window (10 minutes by default) are each sent at once; later ones are sent as one follow-up digest. A notice whose send fails is retried twice. Emails go only to a verified address — one proven through an emailed password link, or one an OAuth provider attested as verified at sign-in (a linked provider alone is not enough) — never to
@localaccounts.Operator CLI actions (
reset_password,create_admin, ...) run outside the API and send no notice. Share keys (read-only, bound to one context) and connector resource tokens do not send notices either.
Two-level role-based access control:
- Workspace level: Owner > Admin > Member > Viewer
- Context level: Owner > Editor > Viewer
- All API routes enforce authentication via FastAPI dependencies
- Workspace and context access validated on every request
3-level isolation ensures complete data separation:
- Workspace ID — organization boundary
- Context ID — project/topic boundary
- User ID — personal boundary (for private contexts)
All Qdrant vector searches and PostgreSQL queries include isolation filters.
- All secrets loaded from environment variables (never hardcoded)
- API keys encrypted with Fernet (AES-128-CBC + HMAC-SHA256)
- API key plaintext never stored — only SHA-256 hash for lookup
.envfiles excluded from git via.gitignore
- Per-user rate limiting via Redis
- Tier-based limits (configurable per plan)
- Per-endpoint overrides for sensitive routes (auth, API key operations)
- Fail-open design (Redis failure doesn't block requests)
- All SQL queries use SQLAlchemy ORM or parameterized
text()— no f-string SQL - Context names validated against
^[a-z0-9_-]+$ - Request body validation via Pydantic models
- UUID format validation on all ID parameters
Severity: Critical (OWASP A01: Broken Access Control / CWE-639: Authorization Bypass Through User-Controlled Key)
Affected versions: all versions before v0.12.0 with Resource Ingest enabled (Issue #238 onward).
Fixed in: v0.12.0
Discovered during: internal design audit (#322 parent epic #321).
Recommendation: All self-hosted operators should upgrade to v0.12.0 as soon as possible. See docs/resource-foundation-migration.md for the step-by-step migration guide.
The Resource Ingest API (POST /api/v1/resources/{resource_id}/events) authenticated tokens by (token_hash, resource_id) only and never verified that the token's creator was a member of the workspace whose Context owned that resource_id. Because contexts.resource_id had no global uniqueness constraint, two workspaces could legitimately create Contexts with the same resource_id string. An authenticated attacker (self-signup + PRO plan) could then:
- Create a Context in their own workspace with the same
resource_idas a victim's Context - Obtain a Resource Token for that
resource_id(the existing per-workspace CRUD check allowed this) - Send ingest events that the victim's indexer would consume and write into the victim's memory store
- Ingest-path workspace boundary:
verify_resource_tokennow enforcesWorkspaceMember.user_id == ResourceToken.created_by AND WorkspaceMember.workspace_id == Context.workspace_id. Mismatches return 403 and emit across_tenant_ingest_attemptstructured warning log. - Schema-level tenant isolation: Alembic migration
a96adds a global partial UNIQUE indexux_contexts_resource_id_active ON contexts (resource_id) WHERE resource_id IS NOT NULL AND deleted_at IS NULL. Cross-workspaceresource_idcollisions are now impossible at the database level. - Audit logging: Structured warnings are emitted for unbound resources, missing token attribution, and membership violations. No raw token material is ever logged — only the integer
token_id(DB PK), the token's public id (rtok_…, since #1008), the workspace UUIDs, and the request's client IP.
- Before upgrading, run the collision audit query to detect any pre-existing active duplicates that would block the new UNIQUE index. Because the index is global on
resource_idfor active rows, both same-workspace and cross-workspace duplicates would abort the migration, so the query must match the index predicate withCOUNT(*) > 1:SELECT resource_id, COUNT(*) AS active_count, COUNT(DISTINCT workspace_id) AS ws_count FROM contexts WHERE resource_id IS NOT NULL AND deleted_at IS NULL GROUP BY resource_id HAVING COUNT(*) > 1;
- If rows are returned, resolve each active duplicate (
UPDATE contexts SET resource_id = ... WHERE id = ...) before upgrading. Thea96migration will abort if any active duplicates remain. - Run
make migrate(or your standard Alembic upgrade step).a96usesCREATE UNIQUE INDEX CONCURRENTLYand does not hold a table lock. - Restart API containers to pick up the updated
verify_resource_tokendependency. - Monitor logs for
cross_tenant_ingest_attemptwarnings — ongoing hits indicate either active exploit attempts or legitimate callers whose token attribution needs review.