Skip to main content

netget/mcp_stdio/
http_guard.rs

1//! Admission policy for the MCP Streamable HTTP endpoint (`--mcp-http`).
2//!
3//! Every tool on that endpoint is a control-plane verb — `start_server` accepts
4//! `event_handlers` whose `script` handlers run `python3 -c <code>` as the operator —
5//! so whoever can POST to `/mcp` can run code. rmcp's transport checks `Accept` and
6//! `Content-Type` and nothing else: no `Origin`, no `Host`, no credential. The MCP
7//! specification requires a localhost server to validate `Origin` precisely because a
8//! web page can reach a loopback port: a plain cross-origin POST is stopped by the
9//! browser's preflight, but DNS rebinding makes the page same-origin with
10//! `127.0.0.1:PORT` and then `fetch("/mcp")` carries no `Origin` at all. What such a
11//! request does carry is the rebound `Host` — the attacker's hostname — which is why
12//! the loopback policy below checks both.
13//!
14//! Two modes, decided once at startup:
15//!
16//! - **Loopback, no token** (the default): the listener must be on a loopback address,
17//!   `Host` (or the HTTP/2 `:authority`) must name a loopback host or the literal bind
18//!   address, and an `Origin`, when a browser sends one, must be a loopback origin.
19//! - **Token** (`--mcp-token` / `NETGET_MCP_TOKEN`): every request must carry
20//!   `Authorization: Bearer <token>`. A rebound or cross-origin page cannot know the
21//!   token, so `Host` and `Origin` are not restricted, which is what lets a web-based
22//!   MCP client on another origin use the endpoint. Binding anything but a loopback
23//!   address *requires* this mode; `HttpGuard::new` refuses otherwise.
24//!
25//! Independently of either mode, a request body is capped at
26//! [`MAX_REQUEST_BODY_BYTES`]: rmcp collects the whole body before parsing it, and
27//! axum's default body limit applies to extractors, not to a nested service reading
28//! the raw body.
29
30use std::net::IpAddr;
31
32use http::header::{AUTHORIZATION, CONTENT_LENGTH, HOST, ORIGIN};
33use http::{HeaderMap, StatusCode, Uri};
34
35/// Environment variable read when `--mcp-token` is not passed.
36pub const TOKEN_ENV: &str = crate::cli::MCP_TOKEN_ENV;
37
38/// Largest request body accepted on `/mcp`. A JSON-RPC tool call is a few kilobytes;
39/// 4 MiB leaves room for a large `event_handlers` table or `initial_memory`.
40pub const MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024;
41
42/// Why a request was turned away, with the status to answer it with.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Refusal {
45    pub status: StatusCode,
46    pub reason: &'static str,
47}
48
49impl Refusal {
50    fn forbidden(reason: &'static str) -> Self {
51        Self {
52            status: StatusCode::FORBIDDEN,
53            reason,
54        }
55    }
56}
57
58/// The admission policy for one listener.
59#[derive(Debug, Clone)]
60pub struct HttpGuard {
61    bind: IpAddr,
62    token: Option<String>,
63}
64
65impl HttpGuard {
66    /// Build the policy for a listener on `bind`.
67    ///
68    /// Refuses a non-loopback bind without a token: that configuration would expose an
69    /// unauthenticated code-execution endpoint to the whole network, and the old
70    /// `--listen-addr` help text ("IP address to bind servers to") led operators to it
71    /// while trying to expose their *protocol* servers.
72    pub fn new(bind: IpAddr, token: Option<String>) -> anyhow::Result<Self> {
73        if let Some(token) = &token {
74            anyhow::ensure!(
75                !token.is_empty() && token.bytes().all(|b| b.is_ascii_graphic()),
76                "the MCP token must be non-empty printable ASCII without spaces"
77            );
78        }
79        if !bind.is_loopback() && token.is_none() {
80            anyhow::bail!(
81                "--mcp-http on {} would expose an unauthenticated control plane to the network \
82                 (every MCP tool runs with this process's privileges, and start_server accepts \
83                 script handlers). Bind a loopback address, or set --mcp-token / {} and require \
84                 a bearer token.",
85                bind,
86                TOKEN_ENV
87            );
88        }
89        Ok(Self { bind, token })
90    }
91
92    /// Whether requests must carry a bearer token.
93    pub fn requires_token(&self) -> bool {
94        self.token.is_some()
95    }
96
97    /// Decide whether a request may reach the MCP service.
98    ///
99    /// `uri` supplies the HTTP/2 `:authority` when there is no `Host` header.
100    pub fn check(&self, headers: &HeaderMap, uri: &Uri) -> Result<(), Refusal> {
101        if let Some(length) = headers
102            .get(CONTENT_LENGTH)
103            .and_then(|v| v.to_str().ok())
104            .and_then(|v| v.trim().parse::<u64>().ok())
105        {
106            if length > MAX_REQUEST_BODY_BYTES as u64 {
107                return Err(Refusal {
108                    status: StatusCode::PAYLOAD_TOO_LARGE,
109                    reason: "request body exceeds the MCP endpoint's limit",
110                });
111            }
112        }
113
114        if let Some(expected) = &self.token {
115            let presented = headers
116                .get(AUTHORIZATION)
117                .and_then(|v| v.to_str().ok())
118                .and_then(bearer_of);
119            return match presented {
120                Some(presented) if constant_time_eq(presented.as_bytes(), expected.as_bytes()) => {
121                    Ok(())
122                }
123                _ => Err(Refusal {
124                    status: StatusCode::UNAUTHORIZED,
125                    reason: "missing or invalid bearer token",
126                }),
127            };
128        }
129
130        // Loopback mode: the request must *name* a loopback host. A DNS-rebound page
131        // reaches this socket with its own hostname in Host.
132        let authority = headers
133            .get(HOST)
134            .and_then(|v| v.to_str().ok())
135            .map(str::to_owned)
136            .or_else(|| uri.authority().map(|a| a.as_str().to_owned()));
137        match authority.as_deref().map(host_of) {
138            Some(host) if self.is_local_host(host) => {}
139            Some(_) => {
140                return Err(Refusal::forbidden(
141                    "Host does not name this loopback listener (DNS rebinding?)",
142                ))
143            }
144            None => return Err(Refusal::forbidden("request names no host")),
145        }
146
147        // Browsers send Origin on cross-site requests and on every POST; a page served
148        // from anywhere but loopback has no business here without a token.
149        if let Some(origin) = headers.get(ORIGIN) {
150            let origin = origin.to_str().unwrap_or("");
151            let allowed = origin
152                .split_once("://")
153                .map(|(_, rest)| self.is_local_host(host_of(rest)))
154                .unwrap_or(false);
155            if !allowed {
156                return Err(Refusal::forbidden(
157                    "Origin is not a loopback origin; set --mcp-token to serve other origins",
158                ));
159            }
160        }
161        Ok(())
162    }
163
164    fn is_local_host(&self, host: &str) -> bool {
165        let host = host.trim_matches(|c| c == '[' || c == ']');
166        if host.eq_ignore_ascii_case("localhost") {
167            return true;
168        }
169        match host.parse::<IpAddr>() {
170            Ok(ip) => ip.is_loopback() || ip == self.bind,
171            Err(_) => false,
172        }
173    }
174}
175
176/// `scheme://host:port/path` or `host:port` → `host` (brackets kept for IPv6, the
177/// caller strips them). A bracketed IPv6 literal may itself contain colons, so the
178/// port is split off only after the closing bracket.
179fn host_of(authority: &str) -> &str {
180    let authority = authority.split('/').next().unwrap_or("");
181    if let Some(rest) = authority.strip_prefix('[') {
182        return match rest.find(']') {
183            Some(end) => &authority[..end + 2],
184            None => authority,
185        };
186    }
187    authority.split(':').next().unwrap_or("")
188}
189
190fn bearer_of(value: &str) -> Option<&str> {
191    let (scheme, token) = value.trim().split_once(' ')?;
192    if !scheme.eq_ignore_ascii_case("bearer") {
193        return None;
194    }
195    let token = token.trim();
196    (!token.is_empty()).then_some(token)
197}
198
199/// Compare without an early exit on the first differing byte, so timing does not
200/// reveal how much of the token a guess got right.
201fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
202    if a.len() != b.len() {
203        return false;
204    }
205    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
206}