Skip to content

Commit d53254d

Browse files
committed
fix(gateway): lock client-management to loopback + harden Host advertising
Addresses the PR review: - S1: the client-management endpoints (GET/PUT/DELETE /oauth/clients[/{id}]) are desktop-only but became LAN-reachable on a 0.0.0.0 bind (the router is shared; rmcp's host allowlist only gates /mcp). Reject them from a non-loopback peer via a ConnectInfo-based middleware (peer IP, not the spoofable Host header). /oauth/clients/{id}/features and the OAuth flow stay reachable. Serve now uses into_make_service_with_connect_info. - S2: validate the Host-derived advertised origin (reject embedded userinfo / path / garbage; preserve port + IPv6) instead of reflecting it verbatim. - B1: document in the Settings warning that per-client OAuth can't complete approval over the network (approval is desktop-local); the supported LAN models are auth-disabled sharing or the public-URL tunnel. All 144 gateway unit tests + 24 streamable_http integration tests pass. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 07c1514 commit d53254d

3 files changed

Lines changed: 126 additions & 9 deletions

File tree

apps/desktop/src/features/settings/SettingsPage.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,13 @@ export function SettingsPage() {
885885
</span>
886886
.
887887
</p>
888+
<p className="mt-1 text-amber-700 dark:text-amber-300">
889+
Per-client OAuth approval happens on this machine, so a remote
890+
client that signs in via OAuth (e.g. ChatGPT) can't finish approval
891+
over the network yet — front the gateway with the public URL + a
892+
tunnel for that. For plain LAN sharing, pair this with
893+
authentication disabled.
894+
</p>
888895
</>
889896
)}
890897
</div>

crates/mcpmux-gateway/src/server/handlers.rs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,27 @@ pub struct OAuthServerMetadata {
6666
pub client_id_metadata_document_supported: Option<bool>,
6767
}
6868

69+
/// Build an `http://HOST` (or `http://HOST:PORT`) origin from a request `Host`
70+
/// header. Returns `None` when the value is not a bare authority — it carries
71+
/// userinfo, a path, or is otherwise unparseable — so the caller keeps the
72+
/// configured local base instead of advertising an attacker-shaped value.
73+
fn host_to_http_origin(host: &str) -> Option<String> {
74+
let parsed = url::Url::parse(&format!("http://{host}")).ok()?;
75+
if !parsed.username().is_empty()
76+
|| parsed.password().is_some()
77+
|| parsed.path() != "/"
78+
|| parsed.query().is_some()
79+
|| parsed.fragment().is_some()
80+
{
81+
return None;
82+
}
83+
let host_str = parsed.host_str()?;
84+
Some(match parsed.port() {
85+
Some(port) => format!("http://{host_str}:{port}"),
86+
None => format!("http://{host_str}"),
87+
})
88+
}
89+
6990
/// The base URL to advertise in OAuth / MCP metadata.
7091
///
7192
/// Precedence:
@@ -85,8 +106,12 @@ pub(crate) fn effective_base_url(
85106
return public.trim_end_matches('/').to_string();
86107
}
87108
if network_bind {
88-
if let Some(host) = host_header.map(str::trim).filter(|s| !s.is_empty()) {
89-
return format!("http://{}", host.trim_end_matches('/'));
109+
if let Some(origin) = host_header
110+
.map(str::trim)
111+
.filter(|s| !s.is_empty())
112+
.and_then(host_to_http_origin)
113+
{
114+
return origin;
90115
}
91116
}
92117
local_fallback.trim_end_matches('/').to_string()
@@ -132,6 +157,32 @@ mod base_url_tests {
132157
LOCAL
133158
);
134159
}
160+
161+
#[test]
162+
fn network_bind_rejects_malformed_host_and_falls_back() {
163+
// Embedded userinfo, a path, or garbage is not advertised.
164+
assert_eq!(
165+
effective_base_url(None, true, Some("user@evil.example"), LOCAL),
166+
LOCAL
167+
);
168+
assert_eq!(
169+
effective_base_url(None, true, Some("evil.example/oauth/authorize"), LOCAL),
170+
LOCAL
171+
);
172+
assert_eq!(effective_base_url(None, true, Some(" "), LOCAL), LOCAL);
173+
}
174+
175+
#[test]
176+
fn network_bind_preserves_port_and_ipv6_host() {
177+
assert_eq!(
178+
effective_base_url(None, true, Some("[::1]:45818"), LOCAL),
179+
"http://[::1]:45818"
180+
);
181+
assert_eq!(
182+
effective_base_url(None, true, Some("host.local"), LOCAL),
183+
"http://host.local"
184+
);
185+
}
135186
}
136187

137188
/// OAuth metadata endpoint (RFC 8414)

crates/mcpmux-gateway/src/server/mod.rs

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ pub use startup::{AutoConnectResult, StartupOrchestrator, TokenRefreshResult};
3030
pub use state::{ClientSession, GatewayState};
3131

3232
use axum::{
33+
extract::ConnectInfo,
3334
middleware,
35+
response::IntoResponse,
3436
routing::{delete, get, post, put},
3537
Router,
3638
};
@@ -160,6 +162,46 @@ impl GatewayConfig {
160162
}
161163
}
162164

165+
/// The desktop-only client-management routes (list / update / delete clients).
166+
/// `/oauth/clients/{id}/features` is intentionally excluded — it is the public
167+
/// client-facing endpoint.
168+
fn is_management_path(path: &str) -> bool {
169+
path == "/oauth/clients"
170+
|| (path.starts_with("/oauth/clients/") && !path.ends_with("/features"))
171+
}
172+
173+
/// Reject the desktop-only client-management endpoints when the request comes
174+
/// from a non-loopback peer.
175+
///
176+
/// On a loopback bind every peer is local, so this is a no-op. On a `0.0.0.0`
177+
/// (network) bind the whole router is exposed, but client enumeration / CRUD
178+
/// must stay off the LAN — the OAuth flow and `/oauth/clients/{id}/features`
179+
/// remain reachable. The peer socket address (not the spoofable `Host` header)
180+
/// is the trust signal. Falls open only when no peer address is available
181+
/// (an embedded/test server without `ConnectInfo`), which never happens on the
182+
/// real network listener.
183+
async fn restrict_management_to_loopback(
184+
request: axum::extract::Request,
185+
next: middleware::Next,
186+
) -> axum::response::Response {
187+
if is_management_path(request.uri().path()) {
188+
let peer_is_local = request
189+
.extensions()
190+
.get::<ConnectInfo<SocketAddr>>()
191+
.map(|info| info.0.ip().is_loopback())
192+
.unwrap_or(true);
193+
if !peer_is_local {
194+
warn!("[Gateway] Rejected non-loopback access to a client-management endpoint");
195+
return (
196+
axum::http::StatusCode::FORBIDDEN,
197+
"Client management is only available from this machine",
198+
)
199+
.into_response();
200+
}
201+
}
202+
next.run(request).await
203+
}
204+
163205
/// MCP Gateway Server
164206
///
165207
/// Self-contained server that manages its own services and lifecycle.
@@ -465,7 +507,9 @@ impl GatewayServer {
465507
))
466508
// Rate limiting on OAuth endpoints
467509
.layer(axum::Extension(rate_limiter))
468-
.layer(middleware::from_fn(rate_limit::rate_limit_middleware));
510+
.layer(middleware::from_fn(rate_limit::rate_limit_middleware))
511+
// Keep desktop-only client management off the LAN on a 0.0.0.0 bind.
512+
.layer(middleware::from_fn(restrict_management_to_loopback));
469513

470514
// Add CORS if enabled
471515
if self.config.enable_cors {
@@ -581,12 +625,15 @@ impl GatewayServer {
581625

582626
info!("[Gateway] Ready to accept connections (servers connecting in background)");
583627

584-
axum::serve(listener, router)
585-
.with_graceful_shutdown(async move {
586-
shutdown.await;
587-
info!("[Gateway] Graceful shutdown signal received — closing listener");
588-
})
589-
.await?;
628+
axum::serve(
629+
listener,
630+
router.into_make_service_with_connect_info::<SocketAddr>(),
631+
)
632+
.with_graceful_shutdown(async move {
633+
shutdown.await;
634+
info!("[Gateway] Graceful shutdown signal received — closing listener");
635+
})
636+
.await?;
590637

591638
info!("[Gateway] Listener closed, run_with_shutdown returning");
592639
Ok(())
@@ -730,4 +777,16 @@ mod config_tests {
730777
.allowed_hosts()
731778
.contains(&"localhost".to_string()));
732779
}
780+
781+
#[test]
782+
fn management_path_matching_excludes_features_and_oauth_flow() {
783+
assert!(super::is_management_path("/oauth/clients")); // list
784+
assert!(super::is_management_path("/oauth/clients/abc123")); // update/delete
785+
// Client-facing + OAuth-flow + other routes are NOT loopback-gated.
786+
assert!(!super::is_management_path("/oauth/clients/abc123/features"));
787+
assert!(!super::is_management_path("/oauth/authorize"));
788+
assert!(!super::is_management_path("/oauth/token"));
789+
assert!(!super::is_management_path("/mcp"));
790+
assert!(!super::is_management_path("/health"));
791+
}
733792
}

0 commit comments

Comments
 (0)