winx_code_agent/http_server.rs
1//! Optional remote MCP transport over Streamable HTTP.
2//!
3//! This is what lets a cloud client (e.g. ChatGPT "developer mode" custom
4//! connectors) talk to winx. The default stdio transport is for local clients
5//! (Claude Desktop, Cursor); this one serves the MCP protocol over HTTP at
6//! `/mcp` so it can sit behind an HTTPS tunnel (cloudflared/ngrok).
7//!
8//! # SECURITY
9//! winx exposes arbitrary shell execution and filesystem access. Serving it over
10//! the network is effectively remote code execution on this machine. Therefore:
11//! - a non-empty bearer token is **required**; every request must present it via
12//! the `Authorization: Bearer <token>` header (header-only — a `?token=` query
13//! parameter would leak the secret into proxy/tunnel logs and browser history);
14//! - bind to a loopback address and put an authenticated HTTPS tunnel in front —
15//! never expose this straight to `0.0.0.0` on an untrusted network;
16//! - turn it off when you're done testing.
17
18// Module docs name products (ChatGPT, OAuth, cloudflared) — prose, not code idents.
19#![allow(clippy::doc_markdown)]
20
21use std::net::SocketAddr;
22use std::sync::Arc;
23use std::time::Duration;
24
25use axum::{
26 extract::{ConnectInfo, Request, State},
27 http::StatusCode,
28 middleware::{self, Next},
29 response::{IntoResponse, Response},
30 Router,
31};
32use rmcp::transport::streamable_http_server::{
33 session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
34};
35use tower_http::limit::RequestBodyLimitLayer;
36use tower_http::timeout::TimeoutLayer;
37
38use crate::server::{SessionIsolation, WinxService};
39
40type BoxError = Box<dyn std::error::Error + Send + Sync>;
41
42/// Reject request bodies larger than this — caps an unbounded-POST DoS on the
43/// network-reachable RCE endpoint. Sized above the 50 MB per-file ceiling
44/// (`MAX_FILE_SIZE` in `read_files`/`file_write_or_edit`) so `FileWriteOrEdit`,
45/// which carries the file content inline in the JSON-RPC body, isn't rejected
46/// for large-but-legitimate writes over HTTP — the stdio transport has no such
47/// limit.
48const MAX_BODY_BYTES: usize = 64 * 1024 * 1024;
49
50/// Per-request wall-clock budget. Long-running shell commands run in the
51/// background (BashCommand `is_background`/`status_check`), so the HTTP request
52/// itself is always short-lived; a stuck request shouldn't pin a connection
53/// forever.
54const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
55
56/// Start the Streamable HTTP MCP server.
57///
58/// - `bind`: socket address to listen on, e.g. `127.0.0.1:8000`.
59/// - `token`: shared secret required on every request (must be non-empty).
60/// - `extra_hosts`: additional `Host` authorities to accept beyond loopback —
61/// add your tunnel hostname here (e.g. `abc.trycloudflare.com`), otherwise the
62/// built-in DNS-rebinding guard rejects requests coming through the tunnel.
63pub async fn start_http_server(
64 bind: &str,
65 token: String,
66 extra_hosts: Vec<String>,
67) -> Result<(), BoxError> {
68 if token.trim().is_empty() {
69 return Err("refusing to start HTTP transport without a token (RCE exposure)".into());
70 }
71
72 let mut config = StreamableHttpServerConfig::default();
73 config.stateful_mode = true;
74 config.allowed_hosts.extend(extra_hosts);
75
76 // One shared WinxService across every request. Remote clients like ChatGPT
77 // are effectively stateless — they don't reuse the MCP session between tool
78 // calls — so a per-session service would throw away the shell that
79 // `Initialize` created before `BashCommand` runs ("Bash state not
80 // initialized"). Sharing one instance keeps shells alive for the server's
81 // lifetime; isolation between logical sessions is provided per `thread_id`
82 // by the service's internal session registry.
83 //
84 // Strict isolation: with many clients behind one shared token, an empty
85 // `thread_id` must NOT fall back to whoever was last active (that would land
86 // one client in another's shell). Strict mode disables that fallback. Two
87 // clients that deliberately reuse the same explicit `thread_id` still share a
88 // shell — real multi-tenant isolation needs per-client tokens, which the
89 // single shared-token model doesn't provide.
90 let shared = WinxService::with_isolation(SessionIsolation::Strict);
91 let mcp_service = StreamableHttpService::new(
92 move || Ok(shared.clone()),
93 Arc::new(LocalSessionManager::default()),
94 config,
95 );
96
97 // Layer order: the LAST `.layer()` is the outermost, so the body-size limit
98 // runs first (it can reject an oversized POST before auth even looks at it),
99 // then the timeout, then the token check, then the MCP service.
100 let app = Router::new()
101 .nest_service("/mcp", mcp_service)
102 .layer(middleware::from_fn_with_state(Arc::new(token), require_token))
103 .layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, REQUEST_TIMEOUT))
104 .layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES));
105
106 let listener = tokio::net::TcpListener::bind(bind).await?;
107 tracing::warn!(
108 "winx remote MCP transport on http://{bind}/mcp — shell/file access is now \
109 network-reachable. Keep it behind an HTTPS tunnel and shut it down when done."
110 );
111 // `into_make_service_with_connect_info` puts the peer address in request
112 // extensions so the auth middleware can log who is hammering the endpoint.
113 axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
114 Ok(())
115}
116
117/// Reject any request that doesn't carry the shared token.
118async fn require_token(
119 State(token): State<Arc<String>>,
120 ConnectInfo(peer): ConnectInfo<SocketAddr>,
121 request: Request,
122 next: Next,
123) -> Response {
124 if request_has_token(&request, &token) {
125 next.run(request).await
126 } else {
127 // Log the peer (never the token) so brute-force attempts on this
128 // RCE-adjacent endpoint are visible.
129 tracing::warn!(
130 "rejected request from {peer} to {} — missing or invalid token",
131 request.uri().path()
132 );
133 (StatusCode::UNAUTHORIZED, "missing or invalid token\n").into_response()
134 }
135}
136
137/// True if the request presents the token via `Authorization: Bearer`.
138///
139/// Header-only by design: a `?token=` query parameter would leak the secret into
140/// proxy/tunnel access logs, browser history, and `Referer` headers. Clients
141/// that need it (ChatGPT connectors, etc.) all support the `Authorization`
142/// header.
143fn request_has_token(request: &Request, expected: &str) -> bool {
144 request
145 .headers()
146 .get(axum::http::header::AUTHORIZATION)
147 .and_then(|value| value.to_str().ok())
148 .and_then(|value| value.strip_prefix("Bearer "))
149 .is_some_and(|presented| constant_time_eq(presented.trim(), expected))
150}
151
152/// Length-aware byte comparison that avoids early-exit on the first mismatch.
153fn constant_time_eq(a: &str, b: &str) -> bool {
154 let (a, b) = (a.as_bytes(), b.as_bytes());
155 if a.len() != b.len() {
156 return false;
157 }
158 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
159}
160
161#[cfg(test)]
162mod tests {
163 #![allow(clippy::unwrap_used)]
164 use super::{constant_time_eq, request_has_token};
165 use axum::body::Body;
166 use axum::extract::Request;
167
168 #[test]
169 fn token_comparison() {
170 assert!(constant_time_eq("s3cret", "s3cret"));
171 assert!(!constant_time_eq("s3cret", "s3creT"));
172 assert!(!constant_time_eq("s3cret", "s3cret-longer"));
173 assert!(!constant_time_eq("", "x"));
174 }
175
176 fn req(uri: &str, auth: Option<&str>) -> Request {
177 let mut b = Request::builder().uri(uri);
178 if let Some(a) = auth {
179 b = b.header(axum::http::header::AUTHORIZATION, a);
180 }
181 b.body(Body::empty()).unwrap()
182 }
183
184 #[test]
185 fn accepts_valid_bearer_header() {
186 assert!(request_has_token(&req("/mcp", Some("Bearer s3cret")), "s3cret"));
187 }
188
189 #[test]
190 fn rejects_missing_and_wrong_header() {
191 assert!(!request_has_token(&req("/mcp", None), "s3cret"));
192 assert!(!request_has_token(&req("/mcp", Some("Bearer nope")), "s3cret"));
193 assert!(!request_has_token(&req("/mcp", Some("s3cret")), "s3cret")); // no "Bearer "
194 }
195
196 #[test]
197 fn query_token_is_rejected_now() {
198 // Header-only: a `?token=` query param must NOT authenticate (it would
199 // leak the secret into proxy/tunnel logs).
200 assert!(!request_has_token(&req("/mcp?token=s3cret", None), "s3cret"));
201 }
202}