git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitcef6d0284389b3e9fb4f623df4ac58278237fe46
parent235e4dba91
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 20:26
fix: the MCP server refuses web pages and oversized requests

The server bound loopback, which keeps the network out but not the
browser. It checked neither Origin nor Host and parsed any body as JSON,
so a page the user had open could POST text/plain to 127.0.0.1:<port>
and run tools blind (cce-notes' append_daily writes into the vault), and
with DNS rebinding read the replies (read_note, search: the whole vault).
Verified against the live cce-designer and cce-notes. A request is now
served only when its Host names loopback and its Origin, if any, does
too, as the MCP transport spec asks; anything else gets 403.

The body was also allocated at whatever Content-Length claimed, and a
failed allocation aborts the process, so one local request could take an
app and its unsaved work down. Bodies are capped at 16 MiB (413), the
whole read is bounded, and a connection that stops sending times out
after 10s instead of holding its thread forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/mcp.rs | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 151 insertions(+), 5 deletions(-)

diff --git a/src/mcp.rs b/src/mcp.rs
index c1f5293..7eb7678 100644
--- a/src/mcp.rs
+++ b/src/mcp.rs
@@ -28,6 +28,56 @@ const PROTOCOL_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
 /// How long a `tools/call` waits on the app's event loop before failing.
 const CALL_TIMEOUT: Duration = Duration::from_secs(30);
 
+/// How long a connection may take to deliver its request. Each connection
+/// holds a thread, so one that never finishes sending must not hold it forever.
+const READ_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// The largest request body read. The body is allocated at the size the
+/// client's `Content-Length` claims, and an allocation that fails aborts the
+/// whole app — not just this thread — so one request claiming 2^63 bytes
+/// would take the window and its unsaved work down with it.
+const MAX_BODY: usize = 16 << 20;
+/// Room for the request line and headers on top of the body.
+const MAX_HEAD: usize = 64 << 10;
+
+/// Whether a request came from a program on this machine rather than from a
+/// web page the user has open.
+///
+/// Binding loopback keeps the network out, not the browser: any page can POST
+/// to `127.0.0.1:<port>` (a `text/plain` body needs no CORS preflight, and
+/// this server parses whatever arrives as JSON), and with DNS rebinding it can
+/// read the replies too — which for cce-notes is the whole vault. Two checks,
+/// as the MCP transport spec asks:
+///
+/// - **`Origin`**, which a browser attaches to every POST, must be absent (an
+///   MCP client is not a browser and sends none) or name loopback. That stops
+///   a page posting from its own origin.
+/// - **`Host`** must name loopback. A rebound page posts to ITS hostname, now
+///   resolving to 127.0.0.1, and that hostname is what arrives here — the one
+///   thing a rebind cannot change.
+fn request_from_this_machine(host: Option<&str>, origin: Option<&str>) -> bool {
+    // "localhost:3002", "[::1]:3002", "127.0.0.1" -> the bare host name.
+    fn hostname(authority: &str) -> &str {
+        if let Some(rest) = authority.strip_prefix('[') {
+            return rest.split(']').next().unwrap_or("");
+        }
+        authority.split(':').next().unwrap_or("")
+    }
+    fn loopback(authority: &str) -> bool {
+        let h = hostname(authority).to_ascii_lowercase();
+        h == "localhost" || h == "::1" || h.parse::<std::net::Ipv4Addr>().is_ok_and(|ip| ip.is_loopback())
+    }
+    let host_ok = host.is_some_and(loopback);
+    let origin_ok = match origin {
+        None => true,
+        Some(o) => o
+            .strip_prefix("http://")
+            .or_else(|| o.strip_prefix("https://"))
+            .is_some_and(|authority| loopback(authority.trim_end_matches('/'))),
+    };
+    host_ok && origin_ok
+}
+
 /// A tool the app exposes over MCP.
 #[derive(Debug, Clone)]
 pub struct McpTool {
@@ -99,31 +149,56 @@ fn handle_connection<F>(stream: TcpStream, server_name: &str, tools: &[McpTool],
 where
     F: Fn(&str, Value) -> Result<Value, String>,
 {
+    let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
     let mut write_stream = match stream.try_clone() {
         Ok(s) => s,
         Err(_) => return,
     };
-    let mut reader = BufReader::new(stream);
+    // Bounded like the body: `read_line` would otherwise buffer one endless
+    // header line for as long as the timeout keeps being met.
+    let mut reader = BufReader::new(stream.take((MAX_BODY + MAX_HEAD) as u64));
     let mut request_line = String::new();
     if reader.read_line(&mut request_line).is_err() {
         return;
     }
 
     let mut content_length = 0usize;
+    let mut host = None;
+    let mut origin = None;
     loop {
         let mut line = String::new();
         if reader.read_line(&mut line).is_err() || line == "\r\n" || line == "\n" || line.is_empty()
         {
             break;
         }
-        let lower = line.to_lowercase();
-        if let Some(rest) = lower.strip_prefix("content-length:") {
-            if let Ok(len) = rest.trim().parse::<usize>() {
-                content_length = len;
+        let Some((name, value)) = line.split_once(':') else { continue };
+        let value = value.trim().to_string();
+        match name.trim().to_ascii_lowercase().as_str() {
+            "content-length" => {
+                if let Ok(len) = value.parse::<usize>() {
+                    content_length = len;
+                }
             }
+            "host" => host = Some(value),
+            "origin" => origin = Some(value),
+            _ => {}
         }
     }
 
+    if !request_from_this_machine(host.as_deref(), origin.as_deref()) {
+        let _ = write_stream.write_all(
+            b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
+        );
+        return;
+    }
+
+    if content_length > MAX_BODY {
+        let _ = write_stream.write_all(
+            b"HTTP/1.1 413 Content Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
+        );
+        return;
+    }
+
     if !request_line.starts_with("POST ") {
         // Stateless server: no SSE stream (GET) or session teardown (DELETE).
         let _ = write_stream.write_all(
@@ -306,4 +381,75 @@ mod tests {
         let resp = handle_jsonrpc(&req, "test", &tools(), &no_calls).unwrap();
         assert_eq!(resp["error"]["code"], -32601);
     }
+
+    #[test]
+    fn only_requests_from_this_machine_are_served() {
+        // An MCP client: no Origin, a loopback Host in any spelling.
+        for host in ["127.0.0.1:3002", "localhost:3002", "LOCALHOST", "[::1]:3002", "127.0.0.2:3002"] {
+            assert!(request_from_this_machine(Some(host), None), "{host}");
+        }
+        // A page on a loopback origin is this machine too.
+        assert!(request_from_this_machine(Some("127.0.0.1:3002"), Some("http://localhost:5173")));
+        assert!(request_from_this_machine(Some("127.0.0.1:3002"), Some("http://[::1]:8080")));
+
+        // A web page posting cross-origin: the Host is ours, the Origin is not.
+        assert!(!request_from_this_machine(Some("127.0.0.1:3002"), Some("https://evil.example")));
+        assert!(!request_from_this_machine(Some("127.0.0.1:3002"), Some("null")));
+        // Look-alikes of loopback are not loopback.
+        assert!(!request_from_this_machine(Some("127.0.0.1:3002"), Some("http://localhost.evil.example")));
+        assert!(!request_from_this_machine(Some("127.0.0.1:3002"), Some("http://127.0.0.1.evil.example")));
+        // DNS rebinding: the page's own hostname arrives as the Host.
+        assert!(!request_from_this_machine(Some("rebind.evil.example:3002"), Some("http://rebind.evil.example:3002")));
+        assert!(!request_from_this_machine(Some("rebind.evil.example:3002"), None));
+        assert!(!request_from_this_machine(Some("localhost.evil.example"), None));
+        // No Host at all is not HTTP/1.1 from anyone we serve.
+        assert!(!request_from_this_machine(None, None));
+    }
+
+    /// Run one raw request through `handle_connection` over a real socket.
+    fn exchange(request: &[u8]) -> String {
+        let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+        let addr = listener.local_addr().unwrap();
+        let server = std::thread::spawn(move || {
+            let (stream, _) = listener.accept().unwrap();
+            handle_connection(stream, "test", &tools(), &|_: &str, args: Value| Ok(args));
+        });
+        let mut client = TcpStream::connect(addr).unwrap();
+        client.write_all(request).unwrap();
+        let _ = client.shutdown(std::net::Shutdown::Write);
+        let mut reply = String::new();
+        let _ = client.read_to_string(&mut reply);
+        server.join().unwrap();
+        reply
+    }
+
+    fn post(host: &str, extra: &str, body: &str) -> Vec<u8> {
+        format!(
+            "POST /mcp HTTP/1.1\r\nHost: {host}\r\n{extra}Content-Length: {}\r\n\r\n{body}",
+            body.len()
+        )
+        .into_bytes()
+    }
+
+    #[test]
+    fn a_web_page_gets_403_and_its_tool_never_runs() {
+        let call = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"x":1}}}"#;
+        let ok = exchange(&post("127.0.0.1:3002", "", call));
+        assert!(ok.starts_with("HTTP/1.1 200"), "{ok}");
+        assert!(ok.contains("isError\":false"), "{ok}");
+
+        let csrf = exchange(&post("127.0.0.1:3002", "Origin: https://evil.example\r\nContent-Type: text/plain\r\n", call));
+        assert!(csrf.starts_with("HTTP/1.1 403"), "{csrf}");
+        let rebind = exchange(&post("rebind.evil.example:3002", "Origin: http://rebind.evil.example:3002\r\n", call));
+        assert!(rebind.starts_with("HTTP/1.1 403"), "{rebind}");
+    }
+
+    #[test]
+    fn a_huge_content_length_is_refused_not_allocated() {
+        // Before the cap this allocated the claimed size, and a failed
+        // allocation aborts the process: this test would take the runner down.
+        let req = "POST /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 9223372036854775807\r\n\r\n{}";
+        let reply = exchange(req.as_bytes());
+        assert!(reply.starts_with("HTTP/1.1 413"), "{reply}");
+    }
 }