Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,21 @@ pub(super) fn internal(request_id: &RequestId, source: AppError) -> CanonicalErr
)
}

/// Rejects browser-page requests to the loopback MCP endpoint. Browser fetches
/// carry `origin` or `sec-fetch-site`; native MCP clients do not.
/// Rejects browser-page requests to the loopback MCP endpoint. `Origin` is the
/// reliable signal: an MCP call is a POST with `application/json`, which is never
/// a CORS simple request, so any webpage capable of making one always carries
/// `Origin`. `Sec-Fetch-*` is not usable here: Chromium/Electron-based desktop
/// MCP clients attach it to every request and cannot strip it, so its presence
/// alone does not indicate a page. The content-type gate below covers the
/// origin-less page vectors (navigations, no-cors simple requests).
async fn mcp_request_hygiene(req: Request, next: Next) -> Response {
// The nested /mcp service shadows the router's `/{*path}` preflight route,
// so answer OPTIONS here to keep loopback preflight behavior consistent.
if *req.method() == Method::OPTIONS {
return StatusCode::NO_CONTENT.into_response();
}
let headers = req.headers();
if headers.contains_key(header::ORIGIN) || headers.contains_key("sec-fetch-site") {
if headers.contains_key(header::ORIGIN) {
Comment thread
DaniAkash marked this conversation as resolved.
return AppError::forbidden("unsupported request").into_response();
}
let needs_json = match *req.method() {
Expand Down
48 changes: 37 additions & 11 deletions packages/browseros-agent/apps/claw-server-rust/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ async fn system_shutdown_preserves_contract_body_and_defers_runtime_teardown() -
async fn mcp_hygiene_rejects_browser_originated_requests() -> anyhow::Result<()> {
let app = test_app().await?;

// `Origin` is the reliable page signal and stays blocked: any webpage that
// can make an MCP call (POST application/json) always carries it.
let (status, _headers, body) = request_json_with_headers(
&app.router,
"POST",
Expand All @@ -298,17 +300,6 @@ async fn mcp_hygiene_rejects_browser_originated_requests() -> anyhow::Result<()>
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body, json!({ "error": "unsupported request" }));

let (status, _headers, body) = request_json_with_headers(
&app.router,
"GET",
"/mcp",
None,
&[("sec-fetch-site", "cross-site")],
)
.await?;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body, json!({ "error": "unsupported request" }));

// Hygiene applies to /mcp only: the same origin header is fine elsewhere.
let (status, _headers, _body) = request_json_with_headers(
&app.router,
Expand All @@ -334,6 +325,41 @@ async fn mcp_hygiene_rejects_browser_originated_requests() -> anyhow::Result<()>
Ok(())
}

#[tokio::test]
async fn mcp_hygiene_admits_electron_sec_fetch_clients() -> anyhow::Result<()> {
let app = test_app().await?;

// Chromium/Electron-based desktop MCP clients (e.g. Cherry Studio) attach
// browser-default Fetch Metadata to every request and cannot strip it. With
// no `Origin`, such a request is not a page fetch and must reach the service.
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "Cherry Studio", "version": "1.0" }
}
});
let (status, headers, body) = request_json_with_headers(
&app.router,
"POST",
"/mcp",
Some(initialize),
&[
("sec-fetch-site", "same-site"),
("sec-fetch-mode", "cors"),
("sec-fetch-dest", "empty"),
],
)
.await?;
assert_eq!(status, StatusCode::OK, "initialize body: {body:?}");
assert_eq!(body["result"]["serverInfo"]["name"], "browseros-neo");
assert!(headers.contains_key("mcp-session-id"));
Ok(())
}

#[tokio::test]
async fn mcp_hygiene_rejects_non_json_writes() -> anyhow::Result<()> {
let app = test_app().await?;
Expand Down
Loading