Technical documentation for spellbook's MCP server security hardening. For a high-level overview, see SECURITY.md in the project root.
graph TD
Client["AI Assistant<br>(Claude Code / Codex / Gemini)"] -->|"HTTP + Bearer token"| Auth["BearerAuthMiddleware<br>(ASGI layer)"]
Client -->|"stdio pipe"| Stdio["stdio transport<br>(no auth needed)"]
Auth -->|"authenticated request"| Dispatch["FastMCP Tool Dispatch"]
Stdio --> Dispatch
Dispatch --> Validation["Input Validation Pipeline<br>(check_tool_input)"]
Validation -->|"safe"| Handler["Tool Handler"]
Validation -->|"blocked"| Block["Return blocked response<br>+ audit log event"]
Handler --> StateVal["State Validation<br>(validate_workflow_state)"]
Handler --> SpawnGuard["Spawn Guard<br>(injection + rate limit + path validation)"]
Handler --> Recovery["Recovery Context<br>(sanitized DB fields)"]
StateVal -->|"valid"| DB["SQLite DB<br>(0600 perms, WAL mode)"]
StateVal -->|"invalid"| Block["Return validation error"]
SpawnGuard -->|"allowed"| Terminal["Terminal Spawn<br>(shlex-escaped)"]
Recovery --> DB
style Auth fill:#e1f5fe
style Validation fill:#e8f5e9
style StateVal fill:#fff3e0
style Block fill:#ffebee
- Generation: On server startup in HTTP mode,
generate_and_store_token()callssecrets.token_urlsafe(32)to produce a 43-character cryptographic token. - Storage: The token is written atomically using
os.open()with flagsO_WRONLY | O_CREAT | O_TRUNCand mode0o600. This avoids the TOCTOU race inherent inPath.write_text()followed byos.chmod(). - Distribution: The token file lives at
~/.local/spellbook/.mcp-token. Clients read this file to obtain the token. - Validation:
BearerAuthMiddlewareextracts theAuthorizationheader, strips theBearerprefix, and compares usingsecrets.compare_digest()(constant-time, prevents timing side-channels). - Expiry: Tokens are per-server-instance. Restarting the server generates a new token, invalidating all prior tokens.
Multiple AI assistant sessions can share a single HTTP server instance. Each session reads the same token file. When the server restarts:
- A new token is generated and written to the token file
- Existing sessions with the old token will receive 401 Unauthorized
- Sessions must re-read the token file to reconnect
| Property | stdio | HTTP (streamable-http) |
|---|---|---|
| Auth required | No (direct pipe, no network) | Yes (bearer token) |
| DNS rebinding risk | None | Mitigated by auth |
| Multi-session | No (one client per pipe) | Yes (shared server) |
| Default | Yes | No (opt-in via env var) |
Source: spellbook/auth.py, spellbook/server.py:build_http_run_kwargs()
The most critical findings (#1 and #2) described a remote code execution kill chain through workflow state persistence. An attacker who can write to the SQLite database (or poison it through a compromised MCP tool) could inject arbitrary commands into the boot_prompt field, which gets executed by the AI assistant on session resume.
Barrier 1: workflow_state_save/update validation (spellbook/server.py)
Both workflow_state_save and workflow_state_update call validate_workflow_state() before writing to the database. The update path validates BOTH the incoming updates AND the merged result, preventing payloads that become dangerous only after merge.
Barrier 2: workflow_state_load rejection (spellbook/resume.py:load_workflow_state())
When loading persisted state, load_workflow_state() re-validates the state. This catches state that was written before validation was added, or state that was tampered with directly in the database.
Barrier 3: boot_prompt content restrictions (spellbook/resume.py:_validate_boot_prompt())
The boot_prompt validator uses context-aware line tracking with two phases:
- Full-string scan: Checks dangerous patterns (
Bash(,Write(,Edit(,WebFetch(,curl,wget,rm -) against the entire boot_prompt. This catches patterns split across lines. - Per-line validation: Each line must match a safe pattern (Skill invocations, Read operations, TodoWrite, markdown formatting) or be inside a tracked multi-line structure (JSON array/object). Lines that match neither are rejected.
Any validation failure raises an error and the write is rejected.
Source: spellbook/resume.py:validate_workflow_state(), spellbook/resume.py:_validate_boot_prompt()
Test: tests/test_workflow_state_security.py
| # | Finding | Severity | File(s) Changed | Fix Approach | Test File |
|---|---|---|---|---|---|
| 1 | RCE via workflow_state_save: arbitrary boot_prompt | CRITICAL | spellbook/resume.py, spellbook/server.py |
Schema validation with allowlisted keys, size caps, boot_prompt content restrictions, dangerous operation blocklist | tests/test_workflow_state_security.py |
| 2 | RCE via workflow_state_update: merge-based injection | CRITICAL | spellbook/server.py |
Pre-merge AND post-merge validation; validates both updates dict and merged result | tests/test_workflow_state_security.py |
| 3 | No authentication on HTTP transport | HIGH | spellbook/auth.py, spellbook/server.py, pyproject.toml |
Bearer token ASGI middleware with atomic token file creation (0600), constant-time comparison, /health exemption | tests/test_auth.py |
| 4 | No rate limiting on spawn_claude_session | HIGH | spellbook/server.py |
DB-backed rate limiter: max 1 spawn per 5 minutes, fail-closed on DB error | tests/test_terminal_security.py |
| 5 | Path traversal via working_directory | HIGH | spellbook/server.py |
_validate_working_directory(): symlink resolution, existence check, scope restriction to $HOME or project dir |
tests/test_terminal_security.py |
| 6 | Prompt injection in spawn prompt | HIGH | spellbook/server.py |
MCP-level security guard: check_tool_input() scan before spawn, audit log on block |
tests/test_terminal_security.py |
| 7 | boot_prompt validation bypass via multi-line evasion | HIGH | spellbook/resume.py |
Context-aware validation with brace/bracket depth tracking; dangerous patterns checked on full string AND per-line | tests/test_workflow_state_security.py, tests/test_resume.py |
| 8 | Shell injection via terminal command inputs | HIGH | spellbook/terminal_utils.py |
shlex.quote() on all user inputs (prompt, working_directory, cli_command) before shell interpolation; AppleScript-specific escaping |
tests/test_terminal_security.py |
| 9 | Recovery context injection via poisoned DB fields | MEDIUM | spellbook/injection.py |
Per-field sanitization with injection pattern detection via do_detect_injection(); fields with injection patterns omitted from context |
tests/test_injection_security.py |
| 10 | Insufficient injection pattern coverage | MEDIUM | spellbook/gates/rules.py |
Added AppleScript injection pattern (APPLESCRIPT-001) and base64-encoded command pipeline pattern (BASE64-001) | tests/test_security/test_pattern_expansion.py |
| 11 | TERMINAL env var used without validation | MEDIUM | spellbook/terminal_utils.py |
Validate via shutil.which() before use; fall back to detection if not found |
tests/test_terminal_security.py |
| 12 | Recovery context field length unbounded | MEDIUM | spellbook/injection.py |
_FIELD_LENGTH_LIMITS dict with per-field caps (100-500 chars); truncation before injection scan |
tests/test_injection_security.py |
| 13 | SPELLBOOK_CLI_COMMAND not validated | MEDIUM | spellbook/terminal_utils.py |
_ALLOWED_CLI_COMMANDS frozenset allowlist; basename extraction prevents path injection; defaults to 'claude' |
tests/test_terminal_security.py |
| 14 | DB file permissions too permissive | LOW | spellbook/db.py |
os.chmod(db_path, 0o600) on connection, os.chmod(db_dir, 0o700) on directory; TTL-based connection cache (1 hour) with health checks |
tests/test_db_security.py |
| Variable | Default | Description |
|---|---|---|
SPELLBOOK_AUTH |
(enabled) | Set to disabled to skip bearer token authentication on HTTP transport. The server logs a warning when auth is disabled. (SPELLBOOK_MCP_AUTH is accepted as a deprecated alias.) |
SPELLBOOK_MCP_HOST |
127.0.0.1 |
Bind address for HTTP transport. Binding to 0.0.0.0 exposes the server to the network and is strongly discouraged. |
SPELLBOOK_MCP_PORT |
8765 |
Port number for HTTP transport. |
SPELLBOOK_MCP_TRANSPORT |
stdio |
Transport mode. stdio for direct pipe (default, used by Claude Code). streamable-http for HTTP with auth. |
SPELLBOOK_CLI_COMMAND |
claude |
CLI command invoked in spawned terminal sessions. Validated against allowlist: claude, codex, gemini, opencode. |
Set the environment variable before starting the server:
SPELLBOOK_MCP_AUTH=disabledThe server will log a warning: MCP auth disabled via SPELLBOOK_MCP_AUTH=disabled.
All security hardening was implemented in discrete, well-scoped commits. To revert a specific finding's fix:
# Example: revert only the auth middleware integration
git revert bd6ed35To revert all security hardening:
git revert --no-commit ab83dc2..HEADThe security audit and hardening drew from 45 sources. The top references: