Skip to main content

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. A `?token=` query parameter is
13//!   rejected by default (it would leak the secret into proxy/tunnel logs and
14//!   browser history) unless the operator opts in with `--allow-query-token`, the
15//!   escape hatch for URL-only clients like ChatGPT;
16//! - bind to a loopback address and put an authenticated HTTPS tunnel in front —
17//!   never expose this straight to `0.0.0.0` on an untrusted network;
18//! - turn it off when you're done testing.
19
20// Module docs name products (ChatGPT, OAuth, cloudflared) — prose, not code idents.
21#![allow(clippy::doc_markdown)]
22
23use std::net::SocketAddr;
24use std::sync::Arc;
25use std::time::Duration;
26
27use axum::{
28    extract::{ConnectInfo, Request, State},
29    http::StatusCode,
30    middleware::{self, Next},
31    response::{IntoResponse, Response},
32    Router,
33};
34use rmcp::transport::streamable_http_server::{
35    session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
36};
37use tower_http::limit::RequestBodyLimitLayer;
38use tower_http::timeout::TimeoutLayer;
39
40use crate::runtime::{configured_shell_runtime, ShellRuntime};
41use crate::server::{SessionIsolation, WinxService};
42
43type BoxError = Box<dyn std::error::Error + Send + Sync>;
44
45/// Reject request bodies larger than this — caps an unbounded-POST DoS on the
46/// network-reachable RCE endpoint. Sized above the 50 MB per-file ceiling
47/// (`MAX_FILE_SIZE` in `read_files`/`file_write_or_edit`) so `FileWriteOrEdit`,
48/// which carries the file content inline in the JSON-RPC body, isn't rejected
49/// for large-but-legitimate writes over HTTP — the stdio transport has no such
50/// limit.
51const MAX_BODY_BYTES: usize = 64 * 1024 * 1024;
52
53/// Per-request wall-clock budget. Long-running shell commands run in the
54/// background (BashCommand `is_background`/`status_check`), so the HTTP request
55/// itself is always short-lived; a stuck request shouldn't pin a connection
56/// forever.
57const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
58
59/// Start the Streamable HTTP MCP server.
60///
61/// - `bind`: socket address to listen on, e.g. `127.0.0.1:8000`.
62/// - `token`: shared secret required on every request (must be non-empty).
63/// - `extra_hosts`: additional `Host` authorities to accept beyond loopback —
64///   add your tunnel hostname here (e.g. `abc.trycloudflare.com`), otherwise the
65///   built-in DNS-rebinding guard rejects requests coming through the tunnel.
66pub async fn start_http_server(
67    bind: &str,
68    token: String,
69    extra_hosts: Vec<String>,
70    allow_query_token: bool,
71) -> Result<(), BoxError> {
72    start_http_server_with_runtime(
73        bind,
74        token,
75        extra_hosts,
76        allow_query_token,
77        configured_shell_runtime().await?,
78    )
79    .await
80}
81
82pub async fn start_http_server_with_runtime(
83    bind: &str,
84    token: String,
85    extra_hosts: Vec<String>,
86    allow_query_token: bool,
87    shell_runtime: Arc<dyn ShellRuntime>,
88) -> Result<(), BoxError> {
89    if token.trim().is_empty() {
90        return Err("refusing to start HTTP transport without a token (RCE exposure)".into());
91    }
92
93    let mut config = StreamableHttpServerConfig::default();
94    config.legacy_session_mode = true;
95    config.allowed_hosts.extend(extra_hosts);
96
97    // One shared WinxService across every request. Remote clients like ChatGPT
98    // are effectively stateless — they don't reuse the MCP session between tool
99    // calls — so a per-session service would throw away the shell that
100    // `Initialize` created before `BashCommand` runs ("Bash state not
101    // initialized"). Sharing one instance keeps shells alive for the server's
102    // lifetime; isolation between logical sessions is provided per `thread_id`
103    // by the service's internal session registry.
104    //
105    // Strict isolation: with many clients behind one shared token, an empty
106    // `thread_id` must NOT fall back to whoever was last active (that would land
107    // one client in another's shell). Strict mode disables that fallback. Two
108    // clients that deliberately reuse the same explicit `thread_id` still share a
109    // shell — real multi-tenant isolation needs per-client tokens, which the
110    // single shared-token model doesn't provide.
111    let shared = WinxService::with_runtime(SessionIsolation::Strict, shell_runtime);
112    let mcp_service = StreamableHttpService::new(
113        move || Ok(shared.clone()),
114        Arc::new(LocalSessionManager::default()),
115        config,
116    );
117
118    // Layer order: the LAST `.layer()` is the outermost, so the body-size limit
119    // runs first (it can reject an oversized POST before auth even looks at it),
120    // then the timeout, then the token check, then the MCP service.
121    let auth = Arc::new(AuthConfig { token, allow_query: allow_query_token });
122    let app = Router::new()
123        .nest_service("/mcp", mcp_service)
124        .layer(middleware::from_fn_with_state(auth, require_token))
125        .layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, REQUEST_TIMEOUT))
126        .layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES));
127
128    let listener = tokio::net::TcpListener::bind(bind).await?;
129    tracing::warn!(
130        "winx remote MCP transport on http://{bind}/mcp — shell/file access is now \
131         network-reachable. Keep it behind an HTTPS tunnel and shut it down when done."
132    );
133    if allow_query_token {
134        tracing::warn!(
135            "--allow-query-token is ON: clients may authenticate via ?token=<secret> in the URL. \
136             Convenient for clients that only take a URL (ChatGPT), but the token will appear in \
137             tunnel/proxy access logs and browser history. Use only on ephemeral/trusted tunnels."
138        );
139    }
140    // `into_make_service_with_connect_info` puts the peer address in request
141    // extensions so the auth middleware can log who is hammering the endpoint.
142    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
143    Ok(())
144}
145
146/// Auth state shared with the token middleware.
147#[derive(Clone)]
148struct AuthConfig {
149    token: String,
150    /// When true, also accept the token via a `?token=` query parameter (opt-in,
151    /// for URL-only clients like ChatGPT). Off by default — see [`request_has_token`].
152    allow_query: bool,
153}
154
155/// Reject any request that doesn't carry the shared token.
156async fn require_token(
157    State(auth): State<Arc<AuthConfig>>,
158    ConnectInfo(peer): ConnectInfo<SocketAddr>,
159    request: Request,
160    next: Next,
161) -> Response {
162    if request_has_token(&request, &auth.token, auth.allow_query) {
163        next.run(request).await
164    } else {
165        // Log the peer (never the token) so brute-force attempts on this
166        // RCE-adjacent endpoint are visible.
167        tracing::warn!(
168            "rejected request from {peer} to {} — missing or invalid token",
169            request.uri().path()
170        );
171        (StatusCode::UNAUTHORIZED, "missing or invalid token\n").into_response()
172    }
173}
174
175/// True if the request presents the token via `Authorization: Bearer`, or — only
176/// when `allow_query` is set — via a `?token=` query parameter.
177///
178/// The header is the default and preferred path. A `?token=` query parameter
179/// leaks the secret into proxy/tunnel access logs, browser history, and `Referer`
180/// headers, so it stays OFF unless the operator explicitly opts in with
181/// `--allow-query-token` (the escape hatch for URL-only clients like ChatGPT).
182fn request_has_token(request: &Request, expected: &str, allow_query: bool) -> bool {
183    let header_ok = request
184        .headers()
185        .get(axum::http::header::AUTHORIZATION)
186        .and_then(|value| value.to_str().ok())
187        .and_then(|value| value.strip_prefix("Bearer "))
188        .is_some_and(|presented| constant_time_eq(presented.trim(), expected));
189    if header_ok {
190        return true;
191    }
192    allow_query
193        && query_token(request).is_some_and(|presented| constant_time_eq(presented, expected))
194}
195
196/// Extract the raw `token=` value from the request's query string, if present.
197///
198/// Minimal parser (no percent-decoding): winx tokens are hex (`openssl rand
199/// -hex`), so they never contain reserved characters. Compared constant-time by
200/// the caller.
201fn query_token(request: &Request) -> Option<&str> {
202    request.uri().query()?.split('&').find_map(|pair| pair.strip_prefix("token="))
203}
204
205/// Constant-time token comparison.
206///
207/// Comparing the raw bytes directly leaks the *length* of the expected token: an
208/// early `a.len() != b.len()` return (or a fold whose iteration count depends on
209/// the inputs) lets an attacker time responses to learn how long the secret is,
210/// shrinking the brute-force space. Instead we hash both sides to fixed 32-byte
211/// SHA-256 digests and compare those: the comparison always runs over 32 bytes
212/// regardless of input length, and hashing the (fixed) secret takes constant time.
213/// A digest match implies an input match by collision resistance.
214fn constant_time_eq(a: &str, b: &str) -> bool {
215    use sha2::{Digest, Sha256};
216    let ha = Sha256::digest(a.as_bytes());
217    let hb = Sha256::digest(b.as_bytes());
218    ha.iter().zip(hb.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
219}
220
221#[cfg(test)]
222mod tests {
223    #![allow(clippy::unwrap_used)]
224    use super::{constant_time_eq, request_has_token};
225    use axum::body::Body;
226    use axum::extract::Request;
227
228    #[test]
229    fn token_comparison() {
230        assert!(constant_time_eq("s3cret", "s3cret"));
231        assert!(!constant_time_eq("s3cret", "s3creT"));
232        assert!(!constant_time_eq("s3cret", "s3cret-longer"));
233        assert!(!constant_time_eq("", "x"));
234    }
235
236    fn req(uri: &str, auth: Option<&str>) -> Request {
237        let mut b = Request::builder().uri(uri);
238        if let Some(a) = auth {
239            b = b.header(axum::http::header::AUTHORIZATION, a);
240        }
241        b.body(Body::empty()).unwrap()
242    }
243
244    #[test]
245    fn accepts_valid_bearer_header() {
246        // Header works regardless of the query-token toggle.
247        assert!(request_has_token(&req("/mcp", Some("Bearer s3cret")), "s3cret", false));
248        assert!(request_has_token(&req("/mcp", Some("Bearer s3cret")), "s3cret", true));
249    }
250
251    #[test]
252    fn rejects_missing_and_wrong_header() {
253        assert!(!request_has_token(&req("/mcp", None), "s3cret", false));
254        assert!(!request_has_token(&req("/mcp", Some("Bearer nope")), "s3cret", false));
255        assert!(!request_has_token(&req("/mcp", Some("s3cret")), "s3cret", false));
256        // no "Bearer "
257    }
258
259    #[test]
260    fn query_token_rejected_when_not_allowed() {
261        // Default (allow_query=false): a `?token=` query param must NOT
262        // authenticate (it would leak the secret into proxy/tunnel logs).
263        assert!(!request_has_token(&req("/mcp?token=s3cret", None), "s3cret", false));
264    }
265
266    #[test]
267    fn query_token_accepted_only_when_opted_in() {
268        // With --allow-query-token, the query param authenticates...
269        assert!(request_has_token(&req("/mcp?token=s3cret", None), "s3cret", true));
270        // ...among other params, and still rejects a wrong value.
271        assert!(request_has_token(&req("/mcp?a=1&token=s3cret&b=2", None), "s3cret", true));
272        assert!(!request_has_token(&req("/mcp?token=nope", None), "s3cret", true));
273        assert!(!request_has_token(&req("/mcp", None), "s3cret", true));
274    }
275}