Skip to content

Commit 6eea108

Browse files
committed
fix(security): escape consent client name; UUID-validate space-config paths
- Escape the attacker-controlled DCR/CIMD `client_name` before interpolating it into the /oauth/authorize consent HTML — closes reflected XSS (HIGH-2). - `get_space_config_path` now parses `space_id` as a UUID, closing path traversal / Windows absolute-path replacement across every space-config command; `open_space_config_file` opens via `tauri-plugin-opener` instead of `cmd /C start`, removing the OS-command-injection path (HIGH-3). - Clone the ClientMetadataService handle out of `GatewayState` and drop the read guard before the CIMD HTTP fetch, so a slow fetch no longer holds the write-preferring state lock across all MCP requests. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 7e22e7a commit 6eea108

5 files changed

Lines changed: 114 additions & 98 deletions

File tree

apps/desktop/src-tauri/src/commands/space.rs

Lines changed: 10 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ pub async fn create_space(
6969
.map_err(|e| e.to_string())?;
7070

7171
// Create default config file for the space (spaces_dir already exists via AppState::new)
72-
let config_path = state.space_config_path(&space.id.to_string());
72+
let config_path = state.space_config_path(&space.id.to_string())?;
7373

7474
// Create default config file if it doesn't exist
7575
if !config_path.exists() {
@@ -143,9 +143,7 @@ pub async fn open_space_config_file(
143143
space_id: String,
144144
state: State<'_, AppState>,
145145
) -> Result<(), String> {
146-
use std::process::Command;
147-
148-
let config_path = state.space_config_path(&space_id);
146+
let config_path = state.space_config_path(&space_id)?;
149147

150148
if !config_path.exists() {
151149
return Err(format!(
@@ -154,32 +152,11 @@ pub async fn open_space_config_file(
154152
));
155153
}
156154

157-
// Open in default editor based on platform
158-
#[cfg(target_os = "windows")]
159-
{
160-
Command::new("cmd")
161-
.args(["/C", "start", "", config_path.to_str().unwrap()])
162-
.spawn()
163-
.map_err(|e| format!("Failed to open file: {}", e))?;
164-
}
165-
166-
#[cfg(target_os = "macos")]
167-
{
168-
Command::new("open")
169-
.arg(&config_path)
170-
.spawn()
171-
.map_err(|e| format!("Failed to open file: {}", e))?;
172-
}
173-
174-
#[cfg(target_os = "linux")]
175-
{
176-
Command::new("xdg-open")
177-
.arg(&config_path)
178-
.spawn()
179-
.map_err(|e| format!("Failed to open file: {}", e))?;
180-
}
181-
182-
Ok(())
155+
// Open with the OS default handler via the opener plugin — never via a
156+
// shell. The previous `cmd /C start <path>` form let cmd.exe interpret
157+
// metacharacters in the (then-unvalidated) path: OS command injection.
158+
tauri_plugin_opener::open_path(&config_path, None::<&str>)
159+
.map_err(|e| format!("Failed to open file: {}", e))
183160
}
184161

185162
/// Read space configuration file
@@ -188,7 +165,7 @@ pub async fn read_space_config(
188165
space_id: String,
189166
state: State<'_, AppState>,
190167
) -> Result<String, String> {
191-
let config_path = state.space_config_path(&space_id);
168+
let config_path = state.space_config_path(&space_id)?;
192169

193170
// Create default config if it doesn't exist (for spaces created before this feature)
194171
if !config_path.exists() {
@@ -210,7 +187,7 @@ pub async fn save_space_config(
210187
content: String,
211188
state: State<'_, AppState>,
212189
) -> Result<(), String> {
213-
let config_path = state.space_config_path(&space_id);
190+
let config_path = state.space_config_path(&space_id)?;
214191

215192
// Validate JSON before saving
216193
serde_json::from_str::<serde_json::Value>(&content)
@@ -226,7 +203,7 @@ pub async fn remove_server_from_config(
226203
server_id: String,
227204
state: State<'_, AppState>,
228205
) -> Result<bool, String> {
229-
let config_path = state.space_config_path(&space_id);
206+
let config_path = state.space_config_path(&space_id)?;
230207

231208
// If config file doesn't exist, nothing to remove
232209
if !config_path.exists() {

apps/desktop/src-tauri/src/state/mod.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,12 @@ impl AppState {
193193
&self.spaces_dir
194194
}
195195

196-
/// Get the path to a specific space's config file
197-
pub fn space_config_path(&self, space_id: &str) -> PathBuf {
196+
/// Get the path to a specific space's config file.
197+
///
198+
/// Fails when `space_id` is not a valid UUID — the id arrives over IPC,
199+
/// so this is the path-traversal guard for every space-config command.
200+
pub fn space_config_path(&self, space_id: &str) -> Result<PathBuf, String> {
198201
mcpmux_core::get_space_config_path(&self.spaces_dir, space_id)
202+
.map_err(|e| format!("Invalid space id '{space_id}': {e}"))
199203
}
200204
}

crates/mcpmux-core/src/lib.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ pub use event_bus::{
3636

3737
use std::path::{Path, PathBuf};
3838

39-
/// Get the path to a space's configuration file (relative to a base spaces directory)
40-
pub fn get_space_config_path(spaces_dir: &Path, space_id: &str) -> PathBuf {
41-
spaces_dir.join(format!("{}.json", space_id))
39+
/// Get the path to a space's configuration file (relative to a base spaces directory).
40+
///
41+
/// `space_id` must parse as a UUID — IPC callers pass attacker-influenceable
42+
/// strings, and joining them raw would allow path traversal (`../..`) or, on
43+
/// Windows, full path replacement (`Path::join` with an absolute path
44+
/// discards the base). The canonical hyphenated form of the *parsed* UUID is
45+
/// used as the filename, never the raw input.
46+
pub fn get_space_config_path(spaces_dir: &Path, space_id: &str) -> Result<PathBuf, uuid::Error> {
47+
let id = uuid::Uuid::parse_str(space_id)?;
48+
Ok(spaces_dir.join(format!("{}.json", id)))
4249
}

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

Lines changed: 76 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -173,66 +173,71 @@ pub async fn oauth_authorize(
173173
);
174174
}
175175

176-
// Resolve and validate client (CIMD or traditional)
177-
{
176+
// Resolve and validate client (CIMD or traditional).
177+
//
178+
// IMPORTANT: clone the service handle out and DROP the state lock before
179+
// `resolve_client()` — CIMD client ids resolve via an outbound HTTP fetch
180+
// (10 s timeout), and `oauth_middleware` takes this same write-preferring
181+
// RwLock on every MCP request, so a read guard held across the fetch plus
182+
// one queued writer would stall all MCP traffic.
183+
let client_metadata_service = {
178184
let gateway_state = state.read().await;
185+
gateway_state.client_metadata_service_arc()
186+
};
187+
let Some(client_metadata_service) = client_metadata_service else {
188+
error!("[OAuth] ClientMetadataService not available");
189+
return oauth_error_redirect(
190+
&params.redirect_uri,
191+
"server_error",
192+
"Service not available",
193+
params.state.as_deref(),
194+
);
195+
};
179196

180-
let client_metadata_service = match gateway_state.client_metadata_service() {
181-
Some(s) => s,
182-
None => {
183-
error!("[OAuth] ClientMetadataService not available");
184-
return oauth_error_redirect(
185-
&params.redirect_uri,
186-
"server_error",
187-
"Service not available",
188-
params.state.as_deref(),
189-
);
190-
}
191-
};
192-
193-
// Resolve client (handles CIMD URL or traditional client_id)
194-
let client = match client_metadata_service
195-
.resolve_client(&params.client_id)
196-
.await
197-
{
198-
Ok(Some(c)) => c,
199-
Ok(None) => {
200-
warn!("[OAuth] Unknown client_id: {}", params.client_id);
201-
return oauth_error_redirect(
202-
&params.redirect_uri,
203-
"invalid_client",
204-
"Client not registered",
205-
params.state.as_deref(),
197+
// Resolve client (handles CIMD URL or traditional client_id). The same
198+
// resolution also yields the consent page's display name — resolve once.
199+
let display_name = match client_metadata_service
200+
.resolve_client(&params.client_id)
201+
.await
202+
{
203+
Ok(Some(client)) => {
204+
// Validate redirect_uri against resolved client.
205+
// Per RFC 8252 §7.3, loopback redirect URIs are matched ignoring the port,
206+
// since native public clients use an ephemeral OS-assigned port at request
207+
// time that may differ from the one captured at DCR.
208+
if !is_redirect_uri_allowed(&client.redirect_uris, &params.redirect_uri) {
209+
warn!(
210+
"[OAuth] Invalid redirect_uri for client: {} (expected one of: {:?})",
211+
params.redirect_uri, client.redirect_uris
206212
);
207-
}
208-
Err(e) => {
209-
error!("[OAuth] Client resolution failed: {}", e);
210213
return oauth_error_redirect(
211214
&params.redirect_uri,
212-
"server_error",
213-
"Client resolution error",
215+
"invalid_redirect_uri",
216+
"Redirect URI not registered for this client",
214217
params.state.as_deref(),
215218
);
216219
}
217-
};
218-
219-
// Validate redirect_uri against resolved client.
220-
// Per RFC 8252 §7.3, loopback redirect URIs are matched ignoring the port,
221-
// since native public clients use an ephemeral OS-assigned port at request
222-
// time that may differ from the one captured at DCR.
223-
if !is_redirect_uri_allowed(&client.redirect_uris, &params.redirect_uri) {
224-
warn!(
225-
"[OAuth] Invalid redirect_uri for client: {} (expected one of: {:?})",
226-
params.redirect_uri, client.redirect_uris
220+
client.client_name
221+
}
222+
Ok(None) => {
223+
warn!("[OAuth] Unknown client_id: {}", params.client_id);
224+
return oauth_error_redirect(
225+
&params.redirect_uri,
226+
"invalid_client",
227+
"Client not registered",
228+
params.state.as_deref(),
227229
);
230+
}
231+
Err(e) => {
232+
error!("[OAuth] Client resolution failed: {}", e);
228233
return oauth_error_redirect(
229234
&params.redirect_uri,
230-
"invalid_redirect_uri",
231-
"Redirect URI not registered for this client",
235+
"server_error",
236+
"Client resolution error",
232237
params.state.as_deref(),
233238
);
234239
}
235-
}
240+
};
236241

237242
// PKCE is required for public clients
238243
if params.code_challenge.is_none() {
@@ -262,19 +267,6 @@ pub async fn oauth_authorize(
262267
params.client_id
263268
);
264269

265-
// Get client display name from metadata service for new clients
266-
let display_name = {
267-
let gateway_state = state.read().await;
268-
if let Some(service) = gateway_state.client_metadata_service() {
269-
match service.resolve_client(&params.client_id).await {
270-
Ok(Some(client)) => client.client_name,
271-
_ => "Unknown Application".to_string(),
272-
}
273-
} else {
274-
"Unknown Application".to_string()
275-
}
276-
};
277-
278270
// Store pending authorization request with expiration (5 minutes)
279271
let request_id = uuid::Uuid::new_v4().to_string();
280272
let expires_at = std::time::SystemTime::now()
@@ -323,6 +315,12 @@ pub async fn oauth_authorize(
323315

324316
let app_name = branding::DISPLAY_NAME;
325317

318+
// HTML-escape the client-supplied display name before interpolating it
319+
// into the consent page — DCR/CIMD `client_name` is attacker-controlled
320+
// (reflected XSS otherwise). The raw name stays on the pending
321+
// authorization for the desktop UI, which renders it as text via React.
322+
let display_name_html = html_escape_text(&display_name);
323+
326324
// HTML page that triggers the deep link
327325
// The page shows a brief message while the app opens
328326
// Industry standard: Don't auto-close, let user close after approval
@@ -435,7 +433,7 @@ pub async fn oauth_authorize(
435433
</p>
436434
437435
<div class="client-info">
438-
<div class="client-name">{display_name}</div>
436+
<div class="client-name">{display_name_html}</div>
439437
<div class="client-id">wants to connect</div>
440438
</div>
441439
@@ -473,6 +471,24 @@ pub async fn oauth_authorize(
473471
axum::response::Html(html).into_response()
474472
}
475473

474+
/// Minimal HTML entity escaping for untrusted text interpolated into
475+
/// gateway-served HTML. Covers every character that can break out of a
476+
/// text node or a double-quoted attribute value.
477+
fn html_escape_text(s: &str) -> String {
478+
let mut out = String::with_capacity(s.len());
479+
for c in s.chars() {
480+
match c {
481+
'&' => out.push_str("&amp;"),
482+
'<' => out.push_str("&lt;"),
483+
'>' => out.push_str("&gt;"),
484+
'"' => out.push_str("&quot;"),
485+
'\'' => out.push_str("&#39;"),
486+
_ => out.push(c),
487+
}
488+
}
489+
out
490+
}
491+
476492
/// Helper to create OAuth error redirect
477493
fn oauth_error_redirect(
478494
redirect_uri: &str,

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@ impl GatewayState {
126126
self.client_metadata_service.as_ref().map(|s| s.as_ref())
127127
}
128128

129+
/// Clone the client metadata service handle out of the state.
130+
///
131+
/// Use this (and drop the state guard) before calling
132+
/// `resolve_client()`: CIMD client ids resolve via an outbound HTTP
133+
/// fetch (10 s timeout), and `GatewayState`'s write-preferring RwLock
134+
/// is taken by `oauth_middleware` on every MCP request — holding a
135+
/// read guard across the fetch can stall all MCP traffic behind one
136+
/// queued writer.
137+
pub fn client_metadata_service_arc(&self) -> Option<Arc<ClientMetadataService>> {
138+
self.client_metadata_service.clone()
139+
}
140+
129141
/// Check if database is connected
130142
pub fn has_database(&self) -> bool {
131143
self.db.is_some()

0 commit comments

Comments
 (0)