From 861577627eff4aae3e2e2fd026228f0aa605b8ad Mon Sep 17 00:00:00 2001
From: Mohammod Al Amin Ashik
Date: Sat, 27 Jun 2026 17:01:44 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat(gateway):=20optional=20network=20acces?=
=?UTF-8?q?s=20=E2=80=94=20bind=200.0.0.0=20for=20LAN=20sharing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an opt-in "Allow access from other devices" setting so the gateway can be
hosted on a VM / shared machine and reached by other clients on the network,
all sharing the same MCP servers. Off by default (binds 127.0.0.1, this machine
only); when on, the gateway binds 0.0.0.0.
- GatewayConfig gains is_network_bind(); allowed_hosts() relaxes to empty
(rmcp treats empty as allow-all) on a non-loopback bind, since LAN clients
reach the gateway by an IP / hostname / mDNS name we can't enumerate up front.
The OAuth + per-client consent layer remains the access gate.
- start_gateway and the auto-start path read the persisted setting and bind
accordingly via bind_host_for().
- New get/set_gateway_network_access commands (persist-only; applies on gateway
restart), mirroring the public-base-url setting.
- Settings UI: a toggle plus a warn-only banner — amber when exposed, red when
exposure is combined with disabled authentication — and a LAN-address hint.
Builds on the public-base-url plumbing (#192). Traffic over 0.0.0.0 is plain
HTTP, so the tunnel/reverse-proxy path (public base URL + TLS) remains the
recommended option for untrusted networks.
Tests: allowed_hosts relaxation + is_network_bind + bind_host_for.
Signed-off-by: Mohammod Al Amin Ashik
---
.../desktop/src-tauri/src/commands/gateway.rs | 71 ++++++++++-
apps/desktop/src-tauri/src/lib.rs | 8 +-
.../src/features/settings/SettingsPage.tsx | 115 ++++++++++++++++++
crates/mcpmux-gateway/src/server/mod.rs | 48 ++++++++
4 files changed, 240 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs
index ad3ae231..0f66f09b 100644
--- a/apps/desktop/src-tauri/src/commands/gateway.rs
+++ b/apps/desktop/src-tauri/src/commands/gateway.rs
@@ -125,6 +125,7 @@ pub(crate) async fn shutdown_gateway_handle(mut handle: mcpmux_gateway::GatewayS
/// mcpmux app instead of the dialog rendering invisibly under another
/// window.
const GATEWAY_PUBLIC_BASE_URL_KEY: &str = "gateway.public_base_url";
+const GATEWAY_NETWORK_ACCESS_KEY: &str = "gateway.network_access_enabled";
pub(crate) fn normalize_public_base_url(raw: &str) -> Result
+
+ Per-client OAuth approval happens on this machine, so a remote
+ client that signs in via OAuth (e.g. ChatGPT) can't finish approval
+ over the network yet — front the gateway with the public URL + a
+ tunnel for that. For plain LAN sharing, pair this with
+ authentication disabled.
+
>
)}
diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs
index f547c1c0..bc86bc4d 100644
--- a/crates/mcpmux-gateway/src/server/handlers.rs
+++ b/crates/mcpmux-gateway/src/server/handlers.rs
@@ -66,6 +66,27 @@ pub struct OAuthServerMetadata {
pub client_id_metadata_document_supported: Option,
}
+/// Build an `http://HOST` (or `http://HOST:PORT`) origin from a request `Host`
+/// header. Returns `None` when the value is not a bare authority — it carries
+/// userinfo, a path, or is otherwise unparseable — so the caller keeps the
+/// configured local base instead of advertising an attacker-shaped value.
+fn host_to_http_origin(host: &str) -> Option {
+ let parsed = url::Url::parse(&format!("http://{host}")).ok()?;
+ if !parsed.username().is_empty()
+ || parsed.password().is_some()
+ || parsed.path() != "/"
+ || parsed.query().is_some()
+ || parsed.fragment().is_some()
+ {
+ return None;
+ }
+ let host_str = parsed.host_str()?;
+ Some(match parsed.port() {
+ Some(port) => format!("http://{host_str}:{port}"),
+ None => format!("http://{host_str}"),
+ })
+}
+
/// The base URL to advertise in OAuth / MCP metadata.
///
/// Precedence:
@@ -85,8 +106,12 @@ pub(crate) fn effective_base_url(
return public.trim_end_matches('/').to_string();
}
if network_bind {
- if let Some(host) = host_header.map(str::trim).filter(|s| !s.is_empty()) {
- return format!("http://{}", host.trim_end_matches('/'));
+ if let Some(origin) = host_header
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .and_then(host_to_http_origin)
+ {
+ return origin;
}
}
local_fallback.trim_end_matches('/').to_string()
@@ -132,6 +157,32 @@ mod base_url_tests {
LOCAL
);
}
+
+ #[test]
+ fn network_bind_rejects_malformed_host_and_falls_back() {
+ // Embedded userinfo, a path, or garbage is not advertised.
+ assert_eq!(
+ effective_base_url(None, true, Some("user@evil.example"), LOCAL),
+ LOCAL
+ );
+ assert_eq!(
+ effective_base_url(None, true, Some("evil.example/oauth/authorize"), LOCAL),
+ LOCAL
+ );
+ assert_eq!(effective_base_url(None, true, Some(" "), LOCAL), LOCAL);
+ }
+
+ #[test]
+ fn network_bind_preserves_port_and_ipv6_host() {
+ assert_eq!(
+ effective_base_url(None, true, Some("[::1]:45818"), LOCAL),
+ "http://[::1]:45818"
+ );
+ assert_eq!(
+ effective_base_url(None, true, Some("host.local"), LOCAL),
+ "http://host.local"
+ );
+ }
}
/// OAuth metadata endpoint (RFC 8414)
diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs
index 5e6c67f4..41de6ac8 100644
--- a/crates/mcpmux-gateway/src/server/mod.rs
+++ b/crates/mcpmux-gateway/src/server/mod.rs
@@ -30,7 +30,9 @@ pub use startup::{AutoConnectResult, StartupOrchestrator, TokenRefreshResult};
pub use state::{ClientSession, GatewayState};
use axum::{
+ extract::ConnectInfo,
middleware,
+ response::IntoResponse,
routing::{delete, get, post, put},
Router,
};
@@ -160,6 +162,46 @@ impl GatewayConfig {
}
}
+/// The desktop-only client-management routes (list / update / delete clients).
+/// `/oauth/clients/{id}/features` is intentionally excluded — it is the public
+/// client-facing endpoint.
+fn is_management_path(path: &str) -> bool {
+ path == "/oauth/clients"
+ || (path.starts_with("/oauth/clients/") && !path.ends_with("/features"))
+}
+
+/// Reject the desktop-only client-management endpoints when the request comes
+/// from a non-loopback peer.
+///
+/// On a loopback bind every peer is local, so this is a no-op. On a `0.0.0.0`
+/// (network) bind the whole router is exposed, but client enumeration / CRUD
+/// must stay off the LAN — the OAuth flow and `/oauth/clients/{id}/features`
+/// remain reachable. The peer socket address (not the spoofable `Host` header)
+/// is the trust signal. Falls open only when no peer address is available
+/// (an embedded/test server without `ConnectInfo`), which never happens on the
+/// real network listener.
+async fn restrict_management_to_loopback(
+ request: axum::extract::Request,
+ next: middleware::Next,
+) -> axum::response::Response {
+ if is_management_path(request.uri().path()) {
+ let peer_is_local = request
+ .extensions()
+ .get::>()
+ .map(|info| info.0.ip().is_loopback())
+ .unwrap_or(true);
+ if !peer_is_local {
+ warn!("[Gateway] Rejected non-loopback access to a client-management endpoint");
+ return (
+ axum::http::StatusCode::FORBIDDEN,
+ "Client management is only available from this machine",
+ )
+ .into_response();
+ }
+ }
+ next.run(request).await
+}
+
/// MCP Gateway Server
///
/// Self-contained server that manages its own services and lifecycle.
@@ -465,7 +507,9 @@ impl GatewayServer {
))
// Rate limiting on OAuth endpoints
.layer(axum::Extension(rate_limiter))
- .layer(middleware::from_fn(rate_limit::rate_limit_middleware));
+ .layer(middleware::from_fn(rate_limit::rate_limit_middleware))
+ // Keep desktop-only client management off the LAN on a 0.0.0.0 bind.
+ .layer(middleware::from_fn(restrict_management_to_loopback));
// Add CORS if enabled
if self.config.enable_cors {
@@ -581,12 +625,15 @@ impl GatewayServer {
info!("[Gateway] Ready to accept connections (servers connecting in background)");
- axum::serve(listener, router)
- .with_graceful_shutdown(async move {
- shutdown.await;
- info!("[Gateway] Graceful shutdown signal received — closing listener");
- })
- .await?;
+ axum::serve(
+ listener,
+ router.into_make_service_with_connect_info::(),
+ )
+ .with_graceful_shutdown(async move {
+ shutdown.await;
+ info!("[Gateway] Graceful shutdown signal received — closing listener");
+ })
+ .await?;
info!("[Gateway] Listener closed, run_with_shutdown returning");
Ok(())
@@ -730,4 +777,16 @@ mod config_tests {
.allowed_hosts()
.contains(&"localhost".to_string()));
}
+
+ #[test]
+ fn management_path_matching_excludes_features_and_oauth_flow() {
+ assert!(super::is_management_path("/oauth/clients")); // list
+ assert!(super::is_management_path("/oauth/clients/abc123")); // update/delete
+ // Client-facing + OAuth-flow + other routes are NOT loopback-gated.
+ assert!(!super::is_management_path("/oauth/clients/abc123/features"));
+ assert!(!super::is_management_path("/oauth/authorize"));
+ assert!(!super::is_management_path("/oauth/token"));
+ assert!(!super::is_management_path("/mcp"));
+ assert!(!super::is_management_path("/health"));
+ }
}
From 569950e40e1ee10abede7e83f48449bdd8980420 Mon Sep 17 00:00:00 2001
From: Mohammod Al Amin Ashik
Date: Sun, 28 Jun 2026 12:25:22 +0800
Subject: [PATCH 4/4] test(gateway): integration coverage for network-bind
metadata advertising
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Drives the real oauth_metadata / resource_metadata handlers over HTTP:
- with network_bind=true, a request whose Host is a remote LAN authority gets
metadata (issuer/endpoints, resource, authorization_servers) advertising that
host — not localhost;
- with network_bind=false, the same Host is ignored and the static base URL is
advertised (local-only behavior unchanged).
Signed-off-by: Mohammod Al Amin Ashik
---
tests/rust/tests/streamable_http/mod.rs | 1 +
.../streamable_http/network_advertising.rs | 190 ++++++++++++++++++
2 files changed, 191 insertions(+)
create mode 100644 tests/rust/tests/streamable_http/network_advertising.rs
diff --git a/tests/rust/tests/streamable_http/mod.rs b/tests/rust/tests/streamable_http/mod.rs
index f470130d..ea942c25 100644
--- a/tests/rust/tests/streamable_http/mod.rs
+++ b/tests/rust/tests/streamable_http/mod.rs
@@ -8,4 +8,5 @@
mod auth_disable;
mod auth_oauth_e2e;
mod gateway_notifications;
+mod network_advertising;
mod notifications;
diff --git a/tests/rust/tests/streamable_http/network_advertising.rs b/tests/rust/tests/streamable_http/network_advertising.rs
new file mode 100644
index 00000000..6793f866
--- /dev/null
+++ b/tests/rust/tests/streamable_http/network_advertising.rs
@@ -0,0 +1,190 @@
+//! Network-bind advertising: when the gateway is bound to a non-loopback
+//! address (`network_bind = true`) the OAuth/MCP discovery metadata must
+//! advertise the host the client actually reached it on (the request `Host`
+//! header) so a remote client gets a resolvable URL instead of `localhost`.
+//! On a loopback bind the static base URL is used regardless of `Host`.
+//!
+//! Drives the real `oauth_metadata` / `resource_metadata` handlers over HTTP.
+
+use axum::{routing::get, Router};
+use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager};
+use mcpmux_gateway::server::{
+ oauth_metadata, resource_metadata, AppState, DependenciesBuilder, GatewayDependencies,
+ GatewayState, ServiceContainer,
+};
+use mcpmux_storage::SqliteSpaceRepository;
+use std::sync::Arc;
+use tokio::sync::broadcast;
+use tokio_util::sync::CancellationToken;
+use uuid::Uuid;
+
+use tests::db::TestDatabase;
+use tests::mocks::*;
+
+struct Harness {
+ base: String,
+ ct: CancellationToken,
+}
+
+impl Harness {
+ /// Boot a gateway serving only the OAuth-discovery endpoints, with inbound
+ /// auth enabled (so metadata is advertised) and the given `network_bind`.
+ /// `public_base_url` stays unset so the Host-derivation path is exercised.
+ async fn start(network_bind: bool) -> Self {
+ let ct = CancellationToken::new();
+ let space_id = Uuid::new_v4();
+
+ let test_db = TestDatabase::in_memory();
+ let database = Arc::new(tokio::sync::Mutex::new(test_db.db));
+
+ let space_repo = Arc::new(SqliteSpaceRepository::new(database.clone()));
+ let space = mcpmux_core::domain::Space {
+ id: space_id,
+ name: "Test Space".to_string(),
+ icon: None,
+ description: None,
+ is_default: true,
+ sort_order: 0,
+ created_at: chrono::Utc::now(),
+ updated_at: chrono::Utc::now(),
+ };
+ mcpmux_core::SpaceRepository::create(&*space_repo, &space)
+ .await
+ .expect("create space");
+ mcpmux_core::SpaceRepository::set_default(&*space_repo, &space_id)
+ .await
+ .expect("set default");
+
+ let deps = DependenciesBuilder::new()
+ .with_installed_server_repo(Arc::new(MockInstalledServerRepository::new()))
+ .with_credential_repo(Arc::new(MockCredentialRepository::new()))
+ .with_backend_oauth_repo(Arc::new(MockOutboundOAuthRepository::new()))
+ .with_feature_repo(Arc::new(MockServerFeatureRepository::new())
+ as Arc)
+ .with_feature_set_repo(Arc::new(MockFeatureSetRepository::new())
+ as Arc)
+ .with_server_discovery(Arc::new(ServerDiscoveryService::new(
+ std::path::PathBuf::from("test-data"),
+ std::path::PathBuf::from("test-spaces"),
+ )))
+ .with_log_manager(Arc::new(ServerLogManager::new(
+ mcpmux_core::LogConfig::default(),
+ )))
+ .with_database(database)
+ .build()
+ .expect("build dependencies");
+ let deps = GatewayDependencies {
+ space_repo: space_repo as Arc,
+ ..deps
+ };
+
+ let (event_tx, _) = broadcast::channel::(64);
+ let mut gw_state = GatewayState::new(event_tx.clone());
+ gw_state.set_base_url("http://127.0.0.1:0".to_string());
+ gw_state.set_network_bind(network_bind);
+ // public_base_url stays None; auth stays enabled so metadata is served.
+ let gateway_state = Arc::new(tokio::sync::RwLock::new(gw_state));
+
+ let services = Arc::new(ServiceContainer::initialize(
+ &deps,
+ event_tx.clone(),
+ gateway_state,
+ ));
+
+ let app_state = AppState {
+ gateway_state: services.gateway_state.clone(),
+ services: services.clone(),
+ base_url: "http://127.0.0.1:0".to_string(),
+ };
+ let router = Router::new()
+ .route(
+ "/.well-known/oauth-authorization-server",
+ get(oauth_metadata),
+ )
+ .route(
+ "/.well-known/oauth-protected-resource/mcp",
+ get(resource_metadata),
+ )
+ .with_state(app_state);
+
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind");
+ let port = listener.local_addr().unwrap().port();
+ let ct_clone = ct.clone();
+ tokio::spawn(async move {
+ axum::serve(listener, router)
+ .with_graceful_shutdown(async move { ct_clone.cancelled().await })
+ .await
+ .unwrap();
+ });
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+
+ Self {
+ base: format!("http://127.0.0.1:{port}"),
+ ct,
+ }
+ }
+}
+
+impl Drop for Harness {
+ fn drop(&mut self) {
+ self.ct.cancel();
+ }
+}
+
+#[tokio::test]
+async fn network_bind_advertises_the_request_host() {
+ let h = Harness::start(true).await;
+ let client = reqwest::Client::new();
+ // The address a remote client reached us on (different from the loopback
+ // socket we actually bound). The advertised metadata must reflect this.
+ let lan = "mcpmux.lan:8080";
+
+ let meta: serde_json::Value = client
+ .get(format!("{}/.well-known/oauth-authorization-server", h.base))
+ .header(reqwest::header::HOST, lan)
+ .send()
+ .await
+ .expect("request")
+ .json()
+ .await
+ .expect("json");
+ assert_eq!(meta["issuer"], format!("http://{lan}"));
+ assert_eq!(
+ meta["authorization_endpoint"],
+ format!("http://{lan}/oauth/authorize")
+ );
+ assert_eq!(meta["token_endpoint"], format!("http://{lan}/oauth/token"));
+
+ let res: serde_json::Value = client
+ .get(format!(
+ "{}/.well-known/oauth-protected-resource/mcp",
+ h.base
+ ))
+ .header(reqwest::header::HOST, lan)
+ .send()
+ .await
+ .expect("request")
+ .json()
+ .await
+ .expect("json");
+ assert_eq!(res["resource"], format!("http://{lan}/mcp"));
+ assert_eq!(res["authorization_servers"][0], format!("http://{lan}"));
+}
+
+#[tokio::test]
+async fn loopback_bind_ignores_request_host() {
+ let h = Harness::start(false).await;
+ let meta: serde_json::Value = reqwest::Client::new()
+ .get(format!("{}/.well-known/oauth-authorization-server", h.base))
+ .header(reqwest::header::HOST, "mcpmux.lan:8080")
+ .send()
+ .await
+ .expect("request")
+ .json()
+ .await
+ .expect("json");
+ // network_bind = false → the configured base URL is advertised, Host ignored.
+ assert_eq!(meta["issuer"], "http://127.0.0.1:0");
+}