Security model
This page states what capsol defends against, how, and — just as deliberately — what it does not.
Threat model
Section titled “Threat model”In scope:
- Stolen or leaked MCP credentials (revocation, expiry, hash-at-rest, constant-time comparison).
- Cross-site request forgery against a logged-in operator (CSRF tokens +
SameSite=Strict). - Cross-capsule access: a credential for capsule A used against capsule B (per-capsule grant binding, enforced at token resolution and again at the storage layer).
- Path traversal via capsule URIs or crafted blob pointers (URI validation, capsule-root canonicalization).
- Brute force against enrollment user codes and credential endpoints (rate limits, 404-without-detail).
- Malicious or compromised content misleading reader agents (trust preambles, provenance footers, risk-class promotion).
- SSRF via OAuth client-metadata documents (HTTPS-only, private-network resolution blocked, size limits, no redirects).
- Token capture from logs (tokens never in URLs — 410; admin key not printed to non-TTY stdout; request logger strips query strings and never logs bodies or Authorization headers).
Out of scope (today):
- A malicious operator: whoever holds the admin key is root over every capsule. There is one trust domain per registry.
- Host compromise: registry state is plaintext JSON on disk (credential hashes only, but capsule content is readable).
- Tamper-proof audit: logs are mutable JSONL (see caveats).
- Multi-tenant isolation between organizations. One registry = one team.
Secret management in production
Section titled “Secret management in production”| Secret | Purpose | Rules |
|---|---|---|
CAPSOL_ADMIN_KEY | Operator/root | ≥16 bytes. Generated keys persist to ~/.capsol/admin.key (mode 0600) with a checksum. The first-run banner prints the plaintext only on a TTY; containers get the file path instead (CAPSOL_PRINT_ADMIN_KEY=1 overrides for CI). |
CAPSOL_SECRET_KEY / _FILE | Encrypts OIDC client secret + SMTP URL (AES-256-GCM), signs CSRF tokens and OIDC state | ≥32 bytes. Production refuses to boot without it. With an ephemeral fallback key (non-production), writing encrypted settings is rejected with secret_key_not_persistent. Generate: openssl rand -base64 48. |
MCP credentials (cbt_…), refresh tokens (crt_…), enrollment tokens | Agent access | 256-bit random, SHA-256 at rest, plaintext returned exactly once, constant-time comparison everywhere. |
Keep the secret key outside CAPSOL_DATA_DIR so a leaked backup tarball does not include it.
CSRF and cookie model
Section titled “CSRF and cookie model”Dashboard auth is an httpOnly capsol_admin cookie holding sha256(admin key) — never the plaintext.
- Both session cookies (
capsol_admin,capsol_operator_identity) areSameSite=Strict,HttpOnly, andSecurein production. The short-lived OIDC state cookie isLaxbecause it must survive the IdP’s top-level redirect. - On login the registry also sets
capsol_csrf: a signed token (nonce.HMAC(secret, nonce‖session)) readable by dashboard JS but not by other origins. - Every cookie-authenticated
POST/PATCH/DELETEmust echo that token inX-Capsol-CSRF(the dashboard fetch helper does) or, for the server-rendered OAuth approval form, in a hiddencsrf_tokenfield. Validation requires the signature and equality with the cookie, which is what invalidates replays after logout. - Requests authenticated purely by
Authorization: Bearerare exempt: cross-site requests cannot carry custom headers, so a bearer credential is never ambient authority. - The guard is mounted as
/v1path-prefix middleware — a newly added route cannot forget it — and the test suite’s route inventory fails if any route appears without a declared auth/CSRF class.
Token lifecycle
Section titled “Token lifecycle”issue ──▶ active ──▶ expired (30d default, CAPSOL_TOKEN_TTL_SECONDS) │ ├─▶ rotated (refresh grant: old token valid until expiry by │ default; revoked immediately under │ CAPSOL_ROTATE_ON_REFRESH=strict) │ └─▶ revoked (dashboard/REST revoke, grant revoke, or POST /oauth/revoke — RFC 7009)- OAuth access tokens get a refresh token; refresh hashes live on the same credential record, and revoking either kills both.
- Revocation is immediate: the very next MCP request fails with
401 token_revoked(orgrant_revokedwhen the operator revoked the grant). - Pausing a connection (
status: "paused") is the reversible control: requests fail withconnection_pausedbut credentials survive reactivation. - Effective scopes are recomputed per request as the intersection of connection ∩ principal ∩ credential ∩ capsule policy, and content scoping is enforced again at the storage layer (
FilteredStore) — the URL is never the security boundary. POST /oauth/revokealways returns 200, so it cannot be used to probe whether a token exists; it is rate-limited per IP.
Audit-log integrity caveats
Section titled “Audit-log integrity caveats”Per-capsule activity logs are append-oriented JSONL files under each capsule’s .capsol/logs/. They record agent, action, URI, sizes, connection/principal/credential ids, and request ids — enough to answer “what did this connection touch”.
They are not tamper-evident: anyone with filesystem access (including the registry process itself) can edit history without detection. There is no signing, no hash chain, no external sink. Treat them as operational telemetry, suitable for incident triage, not as forensic evidence or a compliance audit trail. If you need that, ship the files to an append-only external store as they are written — and know that capsol does not yet do this for you.
Reporting
Section titled “Reporting”Found a vulnerability? Open a private report on the GitHub repository rather than a public issue.