Skip to content

Commit 4768b11

Browse files
committed
Isolate MCP children from the parent environment and bound transport risk
1 parent 7a5c110 commit 4768b11

4 files changed

Lines changed: 301 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ All notable changes to Sofos are documented in this file.
66

77
### Security
88

9+
- **MCP server children no longer inherit the sofos environment.** Stdio MCP servers used to start with every variable the parent shell exported, so `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `MORPH_API_KEY`, ssh agent sockets, and AWS credentials all reached the child. The launcher now clears the environment first and forwards a small allowlist (`PATH`, `HOME` / `USERPROFILE`, `TMPDIR` / `TEMP` / `TMP`, `LANG`, every `LC_*` locale variable, plus Windows essentials like `SYSTEMROOT` and `PATHEXT`); anything else needs to be listed under the server's `env` config field. Existing servers that already declare their required vars in config are unaffected.
10+
- **MCP HTTP transport no longer follows redirects.** A `302` from a hostile or misconfigured MCP host could otherwise forward a configured `Authorization: Bearer ...` header to a different origin. A redirect status now returns a clear error explaining the refusal; reconfigure the server to its final URL instead.
11+
- **MCP HTTP response bodies are capped at 32 MB.** A server that streamed multi-GB JSON for a `tools/list` reply could previously stall a turn and exhaust memory under the 120-second timeout. Sofos now rejects oversized responses up front (when `Content-Length` is announced) or aborts mid-stream when the running total crosses the cap.
12+
- **MCP JSON-RPC responses are matched against the originating request id.** A server that emits an unsolicited frame or replies out of order is rejected with a clear "id mismatch" error instead of returning the wrong result to the caller. The check accepts both numeric and string-shaped echoes of sofos's outgoing numeric id, matching the spec.
13+
- **Slow MCP child shutdown no longer pauses the tokio executor.** The stdio launcher and the timeout-recovery path bound the kill+wait to about 200 ms with non-blocking polls; a server stuck on uninterruptible IO is left to the OS instead of holding up the runtime.
14+
15+
### Changed
16+
17+
- **MCP stdio servers spawn on a background worker.** Process creation used to happen on the tokio executor thread, which could pause the UI on a slow filesystem or NFS mount; it now runs on the blocking-task pool, so the rest of the session stays responsive while a server starts up.
18+
919
- **A global deny rule survives a local allow with the same name.** Adding `Bash(rm)` to `.sofos/config.local.toml`'s allow list used to silently strip the matching `Bash(rm)` from `~/.sofos/config.toml`'s deny list; both entries now coexist after merge, so the per-command verdict reflects every configured rule instead of dropping the global guarantee.
1020
- **`PATH=`, `LD_PRELOAD=`, `LD_LIBRARY_PATH=`, `DYLD_*=`, `NODE_PATH=`, and `PYTHONPATH=` prefixes now route the bash call to a confirmation prompt.** A command like `PATH=. cargo build` used to auto-allow as `cargo`; sofos now asks the user before running anything that swaps the binary the shell will execute, even when the base command is on the allow list or when blanket `Bash` allow is active. Built-in forbidden bases (`rm`, `chmod`, `sudo`, …) still take precedence and stay denied.
1121
- **A session-scoped Bash path grant now applies only to the file the user named.** Allowing `cat /home/me/.ssh/config` once used to permit every other file under `/home/me/.ssh` for the rest of the session; the grant is now scoped to the specific file, so a follow-up `cat /home/me/.ssh/id_rsa` re-prompts. Grants saved to config (yes-and-remember) still cover the whole `parent/**` directory because the user explicitly opts in to that wider scope.

src/mcp/protocol.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ impl From<u64> for Id {
2323
}
2424
}
2525

26+
impl Id {
27+
/// True when this id matches a request id sofos sent. Sofos always
28+
/// sends numeric ids, but the spec lets the server echo them as
29+
/// strings (`"1"` instead of `1`), so the comparison accepts both
30+
/// shapes for the same logical value.
31+
pub fn matches_outgoing(&self, outgoing: u64) -> bool {
32+
match self {
33+
Id::Number(n) => *n == outgoing,
34+
Id::String(s) => s == &outgoing.to_string(),
35+
}
36+
}
37+
}
38+
2639
#[derive(Debug, Clone, Serialize, Deserialize)]
2740
pub struct JsonRpcRequest {
2841
pub jsonrpc: String,

src/mcp/transport/http.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::mcp::client::{
55
};
66
use crate::mcp::config::McpServerConfig;
77
use crate::mcp::protocol::*;
8+
use futures::StreamExt;
89
use serde_json::Value;
910
use std::collections::HashMap;
1011
use std::sync::Arc;
@@ -17,6 +18,11 @@ use std::time::Duration;
1718
/// when the user just wants a quick "server unreachable" signal.
1819
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
1920

21+
/// Cap on a single MCP HTTP response body. A hostile or buggy server
22+
/// can otherwise stream multi-GB JSON for a `tools/list` reply and
23+
/// OOM the host long before the request timeout fires.
24+
const MCP_HTTP_BODY_CAP: usize = 32 * 1024 * 1024;
25+
2026
pub struct HttpClient {
2127
server_name: String,
2228
url: String,
@@ -40,9 +46,16 @@ impl HttpClient {
4046
// connect timeout is shorter than the overall ceiling so an
4147
// unreachable host fails fast instead of holding the full
4248
// request budget on DNS / TCP / TLS.
49+
//
50+
// Redirects are disabled outright. The default reqwest policy
51+
// would follow up to 10 hops and forward custom headers like
52+
// `Authorization: Bearer ...` across origins, leaking the
53+
// bearer token to whatever host the server pointed at. A 3xx
54+
// here surfaces as an explicit error instead.
4355
let client = reqwest::Client::builder()
4456
.timeout(MCP_REQUEST_TIMEOUT)
4557
.connect_timeout(HTTP_CONNECT_TIMEOUT)
58+
.redirect(reqwest::redirect::Policy::none())
4659
.build()
4760
.map_err(|e| SofosError::McpError(format!("Failed to build MCP HTTP client: {}", e)))?;
4861

@@ -119,12 +132,71 @@ impl HttpClient {
119132
))
120133
})?;
121134

122-
let response_json: JsonRpcResponse = response.json().await.map_err(|e| {
135+
let status = response.status();
136+
if status.is_redirection() {
137+
return Err(SofosError::McpError(format!(
138+
"MCP server '{}' returned redirect status {}; refused to follow \
139+
to keep the configured bearer token from leaking cross-origin",
140+
self.server_name, status
141+
)));
142+
}
143+
144+
if let Some(announced) = response.content_length() {
145+
if announced as usize > MCP_HTTP_BODY_CAP {
146+
return Err(SofosError::McpError(format!(
147+
"MCP server '{}' announced a {} byte response, exceeds {} MB cap",
148+
self.server_name,
149+
announced,
150+
MCP_HTTP_BODY_CAP / (1024 * 1024)
151+
)));
152+
}
153+
}
154+
155+
let mut buffered: Vec<u8> = Vec::with_capacity(8 * 1024);
156+
let mut stream = response.bytes_stream();
157+
while let Some(chunk) = stream.next().await {
158+
let chunk = chunk.map_err(|e| {
159+
SofosError::McpError(format!(
160+
"Failed to read response body from MCP server '{}': {}",
161+
self.server_name, e
162+
))
163+
})?;
164+
if buffered.len().saturating_add(chunk.len()) > MCP_HTTP_BODY_CAP {
165+
return Err(SofosError::McpError(format!(
166+
"MCP server '{}' exceeded the {} MB response cap mid-stream",
167+
self.server_name,
168+
MCP_HTTP_BODY_CAP / (1024 * 1024)
169+
)));
170+
}
171+
buffered.extend_from_slice(&chunk);
172+
}
173+
174+
let raw: Value = serde_json::from_slice(&buffered).map_err(|e| {
123175
SofosError::McpError(format!(
124176
"Failed to parse response from MCP server '{}': {}",
125177
self.server_name, e
126178
))
127179
})?;
180+
if raw.get("method").is_some() {
181+
return Err(SofosError::McpError(format!(
182+
"MCP server '{}' sent a server-initiated message; sofos does not \
183+
implement the server-to-client side of the spec",
184+
self.server_name
185+
)));
186+
}
187+
188+
let response_json: JsonRpcResponse = serde_json::from_value(raw).map_err(|e| {
189+
SofosError::McpError(format!(
190+
"Failed to parse response envelope from MCP server '{}': {}",
191+
self.server_name, e
192+
))
193+
})?;
194+
if !response_json.id.matches_outgoing(id) {
195+
return Err(SofosError::McpError(format!(
196+
"MCP server '{}' returned response with id {:?}, expected {}",
197+
self.server_name, response_json.id, id
198+
)));
199+
}
128200

129201
if let Some(error) = response_json.error {
130202
return Err(SofosError::McpError(format!(

0 commit comments

Comments
 (0)