#![allow(clippy::doc_markdown)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::{ConnectInfo, Request, State},
http::StatusCode,
middleware::{self, Next},
response::{IntoResponse, Response},
Router,
};
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::timeout::TimeoutLayer;
use crate::server::{SessionIsolation, WinxService};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const MAX_BODY_BYTES: usize = 64 * 1024 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
pub async fn start_http_server(
bind: &str,
token: String,
extra_hosts: Vec<String>,
allow_query_token: bool,
) -> Result<(), BoxError> {
if token.trim().is_empty() {
return Err("refusing to start HTTP transport without a token (RCE exposure)".into());
}
let mut config = StreamableHttpServerConfig::default();
config.stateful_mode = true;
config.allowed_hosts.extend(extra_hosts);
let shared = WinxService::with_isolation(SessionIsolation::Strict);
let mcp_service = StreamableHttpService::new(
move || Ok(shared.clone()),
Arc::new(LocalSessionManager::default()),
config,
);
let auth = Arc::new(AuthConfig { token, allow_query: allow_query_token });
let app = Router::new()
.nest_service("/mcp", mcp_service)
.layer(middleware::from_fn_with_state(auth, require_token))
.layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, REQUEST_TIMEOUT))
.layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES));
let listener = tokio::net::TcpListener::bind(bind).await?;
tracing::warn!(
"winx remote MCP transport on http://{bind}/mcp — shell/file access is now \
network-reachable. Keep it behind an HTTPS tunnel and shut it down when done."
);
if allow_query_token {
tracing::warn!(
"--allow-query-token is ON: clients may authenticate via ?token=<secret> in the URL. \
Convenient for clients that only take a URL (ChatGPT), but the token will appear in \
tunnel/proxy access logs and browser history. Use only on ephemeral/trusted tunnels."
);
}
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
Ok(())
}
#[derive(Clone)]
struct AuthConfig {
token: String,
allow_query: bool,
}
async fn require_token(
State(auth): State<Arc<AuthConfig>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
request: Request,
next: Next,
) -> Response {
if request_has_token(&request, &auth.token, auth.allow_query) {
next.run(request).await
} else {
tracing::warn!(
"rejected request from {peer} to {} — missing or invalid token",
request.uri().path()
);
(StatusCode::UNAUTHORIZED, "missing or invalid token\n").into_response()
}
}
fn request_has_token(request: &Request, expected: &str, allow_query: bool) -> bool {
let header_ok = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.is_some_and(|presented| constant_time_eq(presented.trim(), expected));
if header_ok {
return true;
}
allow_query
&& query_token(request).is_some_and(|presented| constant_time_eq(presented, expected))
}
fn query_token(request: &Request) -> Option<&str> {
request.uri().query()?.split('&').find_map(|pair| pair.strip_prefix("token="))
}
fn constant_time_eq(a: &str, b: &str) -> bool {
use sha2::{Digest, Sha256};
let ha = Sha256::digest(a.as_bytes());
let hb = Sha256::digest(b.as_bytes());
ha.iter().zip(hb.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::{constant_time_eq, request_has_token};
use axum::body::Body;
use axum::extract::Request;
#[test]
fn token_comparison() {
assert!(constant_time_eq("s3cret", "s3cret"));
assert!(!constant_time_eq("s3cret", "s3creT"));
assert!(!constant_time_eq("s3cret", "s3cret-longer"));
assert!(!constant_time_eq("", "x"));
}
fn req(uri: &str, auth: Option<&str>) -> Request {
let mut b = Request::builder().uri(uri);
if let Some(a) = auth {
b = b.header(axum::http::header::AUTHORIZATION, a);
}
b.body(Body::empty()).unwrap()
}
#[test]
fn accepts_valid_bearer_header() {
assert!(request_has_token(&req("/mcp", Some("Bearer s3cret")), "s3cret", false));
assert!(request_has_token(&req("/mcp", Some("Bearer s3cret")), "s3cret", true));
}
#[test]
fn rejects_missing_and_wrong_header() {
assert!(!request_has_token(&req("/mcp", None), "s3cret", false));
assert!(!request_has_token(&req("/mcp", Some("Bearer nope")), "s3cret", false));
assert!(!request_has_token(&req("/mcp", Some("s3cret")), "s3cret", false));
}
#[test]
fn query_token_rejected_when_not_allowed() {
assert!(!request_has_token(&req("/mcp?token=s3cret", None), "s3cret", false));
}
#[test]
fn query_token_accepted_only_when_opted_in() {
assert!(request_has_token(&req("/mcp?token=s3cret", None), "s3cret", true));
assert!(request_has_token(&req("/mcp?a=1&token=s3cret&b=2", None), "s3cret", true));
assert!(!request_has_token(&req("/mcp?token=nope", None), "s3cret", true));
assert!(!request_has_token(&req("/mcp", None), "s3cret", true));
}
}