Threat model
This page describes Carabase’s threat model — a single-tenant, self-hosted deployment with no public-internet exposure. If you’re considering a configuration that doesn’t match those assumptions (running it on a VPS, exposing the API port publicly, sharing the install across multiple users), some of the protections below stop holding.
The shape of the system
Section titled “The shape of the system”Carabase Host is a single Node.js process that:
- Accepts HTTP on
:3000(default). Bound dual-stack (HOST=::) — it listens on every interface, including the Tailscale virtual interface - Talks to a local Postgres + pgvector
- Optionally talks to a local OpenClaw gateway daemon on
:18789(which only listens on localhost). OpenClaw is the premium chat/agent runtime, not a requirement — a self-host can run entirely on the built-in host-native engine (see Chat is multi-engine) - Spawns background workers in-process via pg-boss + node-cron
No external orchestrator, no public-internet listener, no shared infrastructure with any other Carabase install.
The trust boundary
Section titled “The trust boundary”The network boundary is the primary trust boundary. Anything that can reach :3000 is trusted at the network layer; the routes assume the network is a Tailscale tailnet you control — every device on it is one you signed into. Inside that boundary, several layered defenses still apply (RLS, credential encryption, rate limiting, the MCP access gate) — they’re a safety net, not the front door.
If you remove the network assumption (DDNS, port-forward, VPS without a tailnet), you’ve broken the threat model. There is no per-user authentication on the API routes; the design is one operator per host.
Chat is multi-engine
Section titled “Chat is multi-engine”Chat is not OpenClaw-only. Every conversational turn enters through one route (POST /api/v1/agent/message) which resolves a chat engine from workspace settings:
openclaw— the premium default; wraps the local gateway daemon.host_native— the OSS floor: an in-process AI-SDK loop against the workspace’s own model, grounded and abstaining. No gateway required.codex_cli/claude_code— read-only vendor-CLI bridges that run through the operator’s own localcodex login/claude login. These are read-only by default: write tools are denied at the dispatch seam.
auto picks OpenClaw when a gateway password is configured, otherwise host-native. The vendor-CLI bridges are never auto-selected. This matters for the threat model because a self-host with no gateway is a complete, secure configuration — there’s no OpenClaw to compromise.
What’s defended
Section titled “What’s defended”Cross-workspace data leakage (RLS)
Section titled “Cross-workspace data leakage (RLS)”Every workspace-scoped table has a Postgres Row-Level Security policy keyed on workspace_id. Even if application code forgets a WHERE workspace_id = ? clause, the database refuses to serve cross-workspace rows. The application connects as carabase_app (not the migration superuser), which has RLS enforced. The per-request workspace context is set from the validated x-workspace-id header and cleared on response, so a pooled connection never leaks state to the next request. Background paths (crons, workers, the /mcp surface) that bypass the HTTP middleware set the same context transaction-locally. There’s a regression test that SET ROLEs into the app role and asserts cross-workspace SELECTs return 0 rows — see Workspaces & RLS.
Credential theft from disk
Section titled “Credential theft from disk”OAuth tokens, OAuth client secrets, model-provider API keys, webhook signing secrets, the recoverable MCP gateway token, and backup files are all encrypted at rest with AES-256-GCM (versioned envelope, random 96-bit IV per encryption, GCM auth-tag tamper detection). The key (HOST_MASTER_KEY) is a 256-bit value read from the env file once at startup; without it, encrypt/decrypt fail. Each env (dev / staging / prod) has its own key, so compromising the dev box doesn’t leak prod. OAuth application credentials live encrypted in the database (oauth_apps), never in .env.
Webhook spoofing
Section titled “Webhook spoofing”Inbound webhooks (Slack, Telegram, WhatsApp, Matrix, Linear, plus a generic fallback) verify HMAC-SHA256 signatures with channel-specific signing secrets, using crypto.timingSafeEqual() to prevent timing attacks. Slack additionally enforces a 5-minute timestamp window to block replay attacks, and Linear enforces a replay window at the route layer.
Runaway / abusive clients (rate limiting)
Section titled “Runaway / abusive clients (rate limiting)”Rate limiting is defense-in-depth inside the tailnet, keyed on source IP (the unspoofable boundary — the limiter runs before auth, so it deliberately never buckets on caller-supplied x-workspace-id or bearer, which header rotation could use to escape the ceiling):
- A global limiter caps requests per IP per minute. SPA static assets and cheap liveness pings (
/health,/api/v1/health,/api/v1/version) are exempt so they don’t crowd the operator’s single Tailscale IP bucket and spuriously 429 normal interactive use. - An upload-route limiter tightens the disk-writing routes (attachment / artifact upload / imports): bearer-carrying callers bucket per hashed token (so paired-device batch uploads aren’t throttled as anonymous floods), anonymous callers bucket per IP at a lower cap.
Agent write actions (propose-and-exit)
Section titled “Agent write actions (propose-and-exit)”Any agent workflow that mutates a third-party API goes through a propose-and-exit human-in-the-loop substrate, not a direct upstream call. A skill declares a proposal; a human accepts; an executor atomically claims the row (compare-and-swap, so two workers can’t race) and re-validates the arguments before dispatching. Every pass writes exactly one row to an append-only audit log with sensitive args scrubbed — and that audit log can only be DELETEd through a dedicated maintenance role, not the regular app role. Read it at GET /api/v1/external-actions/audit.
Inbound MCP access
Section titled “Inbound MCP access”The host’s inbound MCP surface (used by tailnet clients like Claude Desktop, Cursor, and Codex, and by the OpenClaw gateway) is behind an access gate. It accepts either a recoverable legacy token or one of N named per-client tokens (stored as SHA-256 hashes, each with a label, scope, and optional expiry). The gate is fail-open only while no token of either kind exists (pure tailnet trust); minting any token turns it on. Token scope encodes a write policy: a read-only token (scope ending -ro) has write tools denied server-side, bound to the token rather than a spoofable header. The read-only vendor-CLI bridges mint short-lived per-run read-only tokens for exactly this reason.
Supply chain
Section titled “Supply chain”- Every GitHub Action is pinned to a commit SHA (not a floating tag), so a compromised maintainer of
actions/checkoutcan’t ship malicious code into the pipeline overnight - Dependabot opens weekly bumps with the new SHA + tag, so SHA pinning doesn’t rot into “pinned-and-stale”
pnpm audit --audit-level=highruns as a CI gate on every PR and release tag — high/critical advisories block merge / publish- GitHub default Code Scanning (CodeQL) runs on every PR and weekly
Stolen device
Section titled “Stolen device”The host depends on physical security of the machine it runs on. If the machine is stolen while powered on, the master key is in memory and credentials can be decrypted. Mitigation: run on an always-on Mac at home with FileVault and require a wake-from-sleep password.
Compromised gateway password (OpenClaw deployments only)
Section titled “Compromised gateway password (OpenClaw deployments only)”If you run the OpenClaw gateway and OPENCLAW_GATEWAY_PASSWORD leaks, an attacker on the same tailnet could speak to the gateway directly and bypass Carabase. Rotate it by generating a fresh value, pasting it into both .env.<env> and the OpenClaw config, and restarting both processes. (A host-native-only deployment has no gateway and is not exposed to this.)
What’s NOT defended
Section titled “What’s NOT defended”- Public-internet exposure (see above — explicitly unsupported)
- Multi-user installs — there’s no per-user authentication on routes; the assumption is one operator per host
- Local privilege escalation — anything running as your user on the host machine can read the master key from
.env.<env>. Mitigation: don’t run untrusted code as your user - Lost master key — backups are encrypted with it; lose the key, lose the backups. Store it in a password manager
- Postgres permissions — the application role has standard CRUD privileges. SQL injection via the Drizzle ORM is not a credible vector, but a future audit should still check
- Destructive tools you wire up yourself — read-only MCP tokens deny write tools, but write-capable tools you explicitly enable (or the external-actions write surface, once a human accepts a proposal) can take real-world action by design. The protection is the propose-and-accept gate, not a sandbox