lean-ctx MCP & HTTP Server — Tool Surface and Transport Security

Published 10 days ago

Component Overview

lean-ctx exposes a Rust-based MCP (Model Context Protocol) server with two transports:

  • stdio (rust/src/mcp_stdio.rs) — spawned by AI agents (Claude Code, Cursor, etc.) as a subprocess
  • Streamable HTTP (rust/src/http_server/mod.rs, axum) — lean-ctx serve exposes JSON-RPC + REST under /v1/*

The HTTP server binds to 127.0.0.1:8080 by default and exposes:

  • /health (unauthenticated)
  • /v1/manifest, /v1/tools — manifest/tool listing
  • /v1/tools/call — POST that invokes any of ~49 ctx_* tools with JSON arguments via ContextEngine::call_tool_value
  • /v1/events — SSE stream of tool call events with replay (?since=N, caller-controlled workspaceId/channelId)
  • /v1/metrics — runtime metrics
  • Fallback: rmcp StreamableHttpService (full MCP JSON-RPC)

Existing Controls

  • Bearer auth via auth_middleware with constant_time_eq comparison
  • validate() refuses non-loopback bind without --auth-token
  • Token-bucket rate limiter (max_rps/burst)
  • Concurrency semaphore
  • Body size limit (default 2 MiB)
  • Per-request timeout (default 30s)
  • rmcp allowed-hosts (DNS rebinding protection) on by default; --disable-host-check available
  • SSE redaction redact_event_payload(RefsOnly) strips payloads from replayed events
  • Per-session LeanCtxServer factory isolates MCP session state

Tool Surface

The 49 ctx_* tools include:

  • ctx_read, ctx_search, ctx_tree — filesystem reads relative to project_root
  • ctx_shell, ctx_execute — shell execution (with internal validator)
  • ctx_edit — file writes
  • ctx_knowledge, ctx_session — persisted state on disk
  • ctx_share, ctx_handoff — cross-workspace data flows
  • ctx_compress, ctx_pack, ctx_multi_read — multi-file embedding

v1/tools/call accepts arbitrary name + arguments JSON; workspace_id/channel_id are caller-controlled.

Trust Boundaries

  • Untrusted: HTTP body, query params, headers, MCP tool arguments
  • Trusted: the local user
  • Risk if exposed beyond loopback: full FS read + arbitrary shell as the user

What Could Go Wrong

  • Path traversal in ctx_read/ctx_search/ctx_edit outside project_root
  • Command injection via ctx_shell argument validation gaps
  • DNS rebinding if --disable-host-check is used or allowed_hosts is misconfigured
  • Auth bypass: timing leaks, missing-token misconfig, /health used as oracle
  • CSRF from a browser on the same machine to 127.0.0.1:8080
  • SSE event leakage: /v1/events serves any caller-supplied workspace/channel id with no per-caller authorization on subscription
  • Workspace/channel ids used as keys in event bus and on-disk paths — injection / traversal risk
  • Resource exhaustion: large ctx_read, deep recursion, huge JSON args
  • Per-session isolation regression if LeanCtxServer shares mutable state via Arc

Tech Stack

Rust, axum, tokio, rmcp, serde_json, tree-sitter

Reviewed

Repo: github.com/yvgude/lean-ctx Commit: ac890e6b4fbc80f5ebbb2c7c42ae95625ad052f3 Branch: main (dirty)

Security Requirements

8
  • CriticalPath Traversal Prevention for ctx_read, ctx_search, and ctx_edit ToolsOPLANE_REQ-00049243

    All filesystem access tools (ctx_read, ctx_search, ctx_edit) must strictly validate and sanitize file paths to ensure that all operations are confined within the designated project_root directory. Reject any path containing traversal sequences (e.g., '../') or absolute paths, and resolve all paths before access to enforce this boundary.

    These tools expose file system operations to untrusted input via HTTP and MCP. Without strict path validation, attackers can exploit path traversal to access sensitive files.

    Failure to implement this requirement could allow attackers to read or modify arbitrary files outside the project directory, leading to data breaches, credential exposure, or system compromise.

    Not Implemented

    No path traversal protection found. read_file_lossy in rust/src/tools/ctx_read.rs accepts arbitrary path strings (only enforces a max-bytes cap via LCTX_MAX_READ_BYTES). detect_project_root(path) is called only to discover where caches/graphs live — not to confine reads. Absolute paths and ../ traversal are not rejected; canonicalization + starts_with(project_root) is not performed. Same applies to ctx_search and ctx_edit. Risk: an attacker (or a prompt-injected AI agent) calling /v1/tools/call with ctx_read({"path": "/etc/passwd"}) or "../../../.ssh/id_rsa" will succeed if not blocked at MCP transport. Mitigating factor: the HTTP server is loopback+bearer by default, so practical exploitation requires an already-present MCP/HTTP client. Still, design should fail-closed.

  • CriticalCommand Injection Mitigation for ctx_shell and ctx_execute ToolsOPLANE_REQ-00049244

    The ctx_shell and ctx_execute tools must robustly validate and sanitize all arguments received from untrusted sources to prevent command injection. Arguments should be parsed and passed as structured data (not concatenated strings), and reject any input containing shell metacharacters or unsafe constructs. Consider using exec APIs that avoid shell interpretation.

    Shell execution tools are exposed via HTTP/MCP and receive untrusted input. Without strict validation, command injection is possible.

    If this requirement is not met, attackers could execute arbitrary commands as the local user, leading to privilege escalation, data exfiltration, or system compromise.

    Partially Implemented

    ctx_shell deliberately invokes the user's shell (sh -c/bash -c/cmd /C) — the design intent is "run arbitrary shell commands and compress the output", so a strict allowlist would defeat the tool's purpose. Existing controls in rust/src/tools/ctx_shell.rs::validate_command: 8192-byte size cap, denylist of file-write redirects (>, >>, tee, heredoc-to-file), and a compound_lexer for parsing &&/||/;. Recursion guard via LEAN_CTX_ACTIVE env var. Output redaction in shell/redact.rs strips secrets. Gaps vs. the requirement's spirit: validation is denylist (not allowlist of structured args); backticks, $(), dd of=, alternate write paths, and exfil via curl/nc are not blocked; arguments still pass through sh -c, so any caller-controlled string is shell-evaluated. Acceptable for a power-user tool consumed by a trusted local AI agent on loopback, but unsafe if the HTTP server is exposed beyond the local user. Recommend documenting the trust model explicitly: ctx_shell is RCE-by-design; only reachable by callers already authorized to run code as the user.

  • HighAuthorization Enforcement for SSE Event Subscription on /v1/eventsOPLANE_REQ-00049245

    The /v1/events SSE endpoint must enforce per-caller authorization checks for workspace_id and channel_id parameters. Only allow event subscriptions to workspaces and channels that the authenticated user is permitted to access. Reject requests for unauthorized workspace/channel combinations.

    Currently, /v1/events allows arbitrary workspace/channel selection with no authorization, exposing event streams to unauthorized users.

    Without this requirement, attackers could subscribe to and replay events from any workspace or channel, leading to sensitive data exposure and cross-workspace information leakage.

    Not Implemented

    /v1/events SSE handler in rust/src/http_server/mod.rs:327-390 reads workspaceId/channelId from query string with .unwrap_or("default") and immediately serves replay+live events for that workspace/channel. There is no per-caller authorization check — bearer-auth middleware only validates that the caller has any valid token; it does not gate which workspaces/channels each token may subscribe to. Same for /v1/tools/call: any bearer-authenticated caller can act in any workspaceId. Mitigating factor: lean-ctx is single-user/local-first, so in the common config (one user, loopback, single token) there is no multi-tenant trust boundary to enforce. Becomes a real vuln in team-server mode or any deployment with multiple tenants sharing one server.

  • HighWorkspace and Channel ID Validation for Event Bus and File OperationsOPLANE_REQ-00049246

    All uses of workspace_id and channel_id as keys in event bus and on-disk paths must validate input to prevent injection and path traversal. Only accept IDs matching a strict pattern (e.g., alphanumeric, length limit), and reject any input containing path separators or traversal sequences.

    workspace_id and channel_id are caller-controlled and used in sensitive contexts; improper validation enables injection and traversal attacks.

    If not enforced, attackers could inject malicious IDs to manipulate event routing or access arbitrary files/directories, leading to data corruption or unauthorized access.

    Partially Implemented

    normalize_id in rust/src/core/context_os/shared_sessions.rs:102 filters workspace/channel ids to ASCII alphanumeric + -_. and falls back to "default" on empty. This is applied in SharedSessionKey::new before joining into on-disk path data/context-os/sessions/<project_hash>/<workspace_id>/<channel_id>, so on-disk path traversal via these ids is mitigated. Gap: the HTTP server (rust/src/http_server/mod.rs:303,333) and event bus (rust/src/core/context_os/context_bus.rs) use the raw caller-supplied strings as-is — bus stores them in SQLite and matches them in /v1/events without normalization. Length is unbounded. So while the dangerous path-key use is normalized, log/event-key use is not. Recommend pushing normalize_id (with explicit length cap, e.g. 64 chars) into the HTTP layer or a newtype WorkspaceId/ChannelId enforced at every entry point, and rejecting (rather than silently filtering) invalid input so callers learn about errors.

  • HighConstant-Time Bearer Token Comparison for Authentication MiddlewareOPLANE_REQ-00049249

    Authentication middleware must use constant-time comparison for bearer tokens to prevent timing attacks. Ensure that all token comparisons are implemented using constant_time_eq or equivalent, and audit for any code paths that could leak timing information.

    Timing attacks on token comparison are a practical risk for authentication mechanisms exposed to untrusted input.

    If token comparison is not constant-time, attackers can exploit timing differences to guess valid tokens, leading to authentication bypass.

    Implemented

    rust/src/http_server/mod.rs::auth_middleware uses constant_time_eq(token.as_bytes(), expected.as_bytes()) defined at lines 189-197 — XOR-fold over zipped bytes. Length is checked first (if a.len() != b.len()), which is itself a length oracle, but for opaque random bearer tokens of fixed server-issued length this is acceptable: only the length of the EXPECTED token leaks, and the expected token is fixed at server start. The implementation rejects malformed Authorization headers via let Some(...) else patterns that all return UNAUTHORIZED on the same code path. Recommend either using the subtle crate's ConstantTimeEq for clarity, or padding before compare if variable-length tokens are ever used. /health bypass is intentional and safe.

  • HighDNS Rebinding Protection for HTTP Server Host ChecksOPLANE_REQ-00049247

    The HTTP server must enforce allowed-hosts checks by default and refuse requests from non-loopback addresses unless explicitly configured with a secure authentication token. The --disable-host-check option must be clearly documented as dangerous and should not be enabled in production.

    Host checks are critical for preventing remote access via DNS rebinding, especially when the server exposes powerful tool surfaces.

    Disabling host checks or misconfiguring allowed_hosts exposes the server to DNS rebinding attacks, allowing remote attackers to access privileged local endpoints.

    Implemented

    rust/src/http_server/mod.rs::HttpServerConfig::validate refuses non-loopback bind without --auth-token. mcp_http_config() applies rmcp's allowed_hosts (loopback default) and only adds the configured host if it is loopback; --disable-host-check bypasses but is opt-in. Defaults are safe: 127.0.0.1:8080 with allowed_hosts=[127.0.0.1]. Caveats: no startup warning is logged when --disable-host-check is set (recommend tracing::warn at startup) and SECURITY.md does not yet document --disable-host-check as dangerous (recommend adding). No protection against Unicode-lookalike host headers, but rmcp's allowed_hosts is exact-match so this is implicitly handled.

  • MediumResource Exhaustion Mitigation for Tool Calls and Event StreamsOPLANE_REQ-00049248

    Implement strict limits on resource usage for tool calls (e.g., maximum file size, recursion depth, JSON argument size) and event stream subscriptions. Reject requests that exceed configured thresholds and ensure cleanup of resources on timeout or error.

    Untrusted input can trigger expensive operations; limits are necessary to maintain system availability and prevent abuse.

    Without resource limits, attackers can exhaust system resources, causing denial of service or degraded performance for legitimate users.

    Partially Implemented

    HTTP layer enforces several limits: max_body_bytes (2 MiB default, axum DefaultBodyLimit), max_concurrency semaphore, token-bucket rate limiter (max_rps/rate_burst), per-request timeout (30s default via tokio::time::timeout). ctx_read enforces LCTX_MAX_READ_BYTES via core::limits::max_read_bytes(). Gaps: serde_json deserialization has no recursion-depth or size limit beyond body cap (deeply nested JSON within 2 MiB still parses); no per-user / per-workspace stream limit on /v1/events (a single caller can open many SSE subscriptions, bounded only by global semaphore); ctx_search / ctx_tree / multi-file tools have no per-result count cap visible in the dispatch layer; no documented cap on recursion depth in graph traversal or import resolver. Resource cleanup on disconnect for SSE relies on rmcp/axum default behavior — no explicit user stream tracker. Recommend serde_json recursion limit and an SSE-per-token cap.

  • MediumSession State Isolation for LeanCtxServer InstancesOPLANE_REQ-00049250

    Ensure that each LeanCtxServer instance maintains fully isolated session state, with no shared mutable data (e.g., via Arc) between sessions. Audit all session-related code for inadvertent state sharing, and enforce per-session boundaries.

    Session isolation is critical for security and correctness when serving multiple concurrent users or agents.

    Failure to isolate session state can lead to cross-session data leakage, privilege escalation, or unpredictable behavior.

    Implemented

    rust/src/http_server/mod.rs::serve uses a service_factory closure that constructs a fresh LeanCtxServer::new_shared_with_context(...) for each MCP session, passed to rmcp's StreamableHttpService with LocalSessionManager. Test mcp_service_factory_isolates_per_client_state (lines 564-590) explicitly verifies that per-client mutable state (client_name) does not clobber across factory invocations. Note the design intentionally SHARES the Context OS store (event bus, persisted session state) across MCP sessions — new_shared_with_context — because that is required for the cross-session features (ctx_handoff, ctx_share, ctx_session). The shared store is internally synchronized (RwLock/Mutex/SQLite) and is keyed by workspace_id/channel_id. So per-MCP-session ephemeral state IS isolated; cross-session shared state IS intentional and synchronized. The remaining concern is that workspace_id/channel_id authorization is not enforced (covered separately in REQ-00049245) — that is the actual cross-session leakage path, not session state isolation per se.