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}