Skip to content

Commit 05a8ed7

Browse files
committed
fix(security): prevent unauthorized OAuth consent approval via HTTP
The /oauth/consent/approve HTTP endpoint was publicly accessible on localhost:45818 with no authentication, allowing any local process (including malicious scripts) to auto-approve MCP client authorization requests without user interaction. This is how clients like Cursor could bypass the deep-link consent flow entirely. Changes: - Remove /oauth/consent/approve HTTP endpoint from production router (consent now exclusively via Tauri IPC command) - Add cryptographic consent_token to PendingAuthorization, generated per-request and shared only via Tauri IPC (get_pending_consent) — must be returned on approval to prove legitimacy - Add 2-second UI cooldown on Approve button to prevent programmatic instant approval - Add per-path rate limiting middleware on OAuth endpoints (authorize, token, register, clients) - Guard approve_oauth_client Tauri command behind MCPMUX_E2E_TEST env var (was unrestricted "for E2E testing") - Conditionally re-enable HTTP consent endpoint only when MCPMUX_E2E_TEST=1 for E2E test compatibility Signed-off-by: Claude <noreply@anthropic.com> https://claude.ai/code/session_019gtFefwPpKWZH73DEyUpE2 Signed-off-by: Claude <noreply@anthropic.com>
1 parent 9c63e49 commit 05a8ed7

6 files changed

Lines changed: 252 additions & 11 deletions

File tree

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

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ pub struct ConsentRequestDetails {
7474
pub state: Option<String>,
7575
/// When this request expires (Unix timestamp)
7676
pub expires_at: i64,
77+
/// Cryptographic consent token (shared only via this IPC call, never over HTTP).
78+
/// Must be returned in the approval request to prove the caller is the
79+
/// legitimate desktop app UI—not an external script or bot.
80+
pub consent_token: String,
7781
}
7882

7983
/// Handle an incoming deep link URL
@@ -360,6 +364,16 @@ pub async fn get_pending_consent(
360364
return Err(ConsentError::expired(&request_id));
361365
}
362366

367+
// Extract consent_token (required for security—ensures only the desktop
368+
// app that retrieved this token via IPC can approve the request)
369+
let consent_token = auth.consent_token.clone().ok_or_else(|| {
370+
error!("[OAuth] Pending authorization missing consent_token");
371+
ConsentError {
372+
code: "NOT_FOUND".to_string(),
373+
message: "Authorization request is missing consent token — it may have been created before this security update. Please retry.".to_string(),
374+
}
375+
})?;
376+
363377
// Build response with authoritative data from backend
364378
// The client_name here comes from our database lookup in handlers.rs
365379
let details = ConsentRequestDetails {
@@ -373,6 +387,7 @@ pub async fn get_pending_consent(
373387
scope: auth.scope.clone().unwrap_or_default(),
374388
state: auth.state.clone(),
375389
expires_at: auth.expires_at,
390+
consent_token,
376391
};
377392

378393
info!(
@@ -390,6 +405,9 @@ pub struct ConsentApprovalRequest {
390405
pub request_id: String,
391406
/// Whether the user approved the request
392407
pub approved: bool,
408+
/// Cryptographic consent token (must match the one issued via get_pending_consent).
409+
/// This proves the caller obtained the token through Tauri IPC, not HTTP scraping.
410+
pub consent_token: String,
393411
/// Optional alias name for the client
394412
pub client_alias: Option<String>,
395413
/// Connection mode: "follow_active", "locked", or "ask_on_change"
@@ -453,6 +471,27 @@ pub async fn approve_oauth_consent(
453471
});
454472
};
455473

474+
// Validate consent_token: proves the caller obtained this token via Tauri
475+
// IPC (get_pending_consent), not by scraping the HTTP authorization page.
476+
match &pending.consent_token {
477+
Some(expected_token) => {
478+
if request.consent_token != *expected_token {
479+
error!(
480+
"[OAuth] Consent token mismatch for request_id: {} — possible unauthorized approval attempt",
481+
request.request_id
482+
);
483+
return Err("Invalid consent token".to_string());
484+
}
485+
}
486+
None => {
487+
error!(
488+
"[OAuth] Pending authorization missing consent_token for request_id: {}",
489+
request.request_id
490+
);
491+
return Err("Consent token not available".to_string());
492+
}
493+
}
494+
456495
// Remove the pending authorization (it's been processed)
457496
{
458497
let mut state = gw_state.write().await;
@@ -504,6 +543,7 @@ pub async fn approve_oauth_consent(
504543
code_challenge: pending.code_challenge.clone(),
505544
code_challenge_method: pending.code_challenge_method.clone(),
506545
expires_at: code_expires_at,
546+
consent_token: None, // Auth code entries don't need consent tokens
507547
};
508548

509549
state.store_pending_authorization(&code, new_pending);
@@ -656,13 +696,19 @@ pub async fn get_oauth_clients(
656696
Ok(client_infos)
657697
}
658698

659-
/// Approve a registered OAuth client by ID (for E2E testing).
660-
/// In production, clients are approved via the consent flow.
699+
/// Approve a registered OAuth client by ID (for E2E testing only).
700+
///
701+
/// Guarded by the `MCPMUX_E2E_TEST` environment variable. In production
702+
/// builds this command is a no-op that returns an error.
661703
#[tauri::command]
662704
pub async fn approve_oauth_client(
663705
client_id: String,
664706
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
665707
) -> Result<(), String> {
708+
if std::env::var("MCPMUX_E2E_TEST").is_err() {
709+
return Err("approve_oauth_client is only available in E2E test mode".to_string());
710+
}
711+
666712
let app_state = gateway_state.read().await;
667713
let Some(ref gw_state) = app_state.gateway_state else {
668714
return Err("Gateway not running".to_string());
@@ -674,7 +720,10 @@ pub async fn approve_oauth_client(
674720
repo.approve_client(&client_id)
675721
.await
676722
.map_err(|e| format!("Failed to approve client: {}", e))?;
677-
info!("[OAuth] Approved client via test command: {}", client_id);
723+
info!(
724+
"[OAuth] Approved client via E2E test command: {}",
725+
client_id
726+
);
678727
Ok(())
679728
}
680729

apps/desktop/src/components/OAuthConsentModal.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ interface ConsentRequestDetails {
5757
scope: string;
5858
state: string | null;
5959
expiresAt: number;
60+
/** Cryptographic token shared only via Tauri IPC—must be sent back on approval */
61+
consentToken: string;
6062
}
6163

6264
/** Error from get_pending_consent */
@@ -124,6 +126,8 @@ export function OAuthConsentModal() {
124126
const [spaces, setSpaces] = useState<Space[]>([]);
125127
const [isProcessing, setIsProcessing] = useState(false);
126128
const [processError, setProcessError] = useState<string | null>(null);
129+
/** 2-second cooldown before the Approve button becomes active */
130+
const [approveReady, setApproveReady] = useState(false);
127131

128132
// Load spaces when modal opens
129133
useEffect(() => {
@@ -132,6 +136,17 @@ export function OAuthConsentModal() {
132136
}
133137
}, [modalState.type]);
134138

139+
// 2-second cooldown: prevents instant automated approval by requiring the
140+
// consent modal to be visible for at least 2 seconds before Approve is active.
141+
useEffect(() => {
142+
if (modalState.type === 'consent') {
143+
setApproveReady(false);
144+
const timer = setTimeout(() => setApproveReady(true), 2000);
145+
return () => clearTimeout(timer);
146+
}
147+
setApproveReady(false);
148+
}, [modalState.type]);
149+
135150
useEffect(() => {
136151
// Listen for OAuth consent requests from the backend (deep link)
137152
const unlisten = listen<OAuthDeepLinkPayload>('oauth-consent-request', async (event) => {
@@ -178,6 +193,7 @@ export function OAuthConsentModal() {
178193
request: {
179194
request_id: details.requestId,
180195
approved: true,
196+
consent_token: details.consentToken,
181197
client_alias: clientAlias || null,
182198
connection_mode: connectionMode,
183199
locked_space_id: connectionMode === 'locked' ? lockedSpaceId : null,
@@ -211,6 +227,7 @@ export function OAuthConsentModal() {
211227
request: {
212228
request_id: details.requestId,
213229
approved: false,
230+
consent_token: details.consentToken,
214231
client_alias: null,
215232
},
216233
});
@@ -467,14 +484,14 @@ export function OAuthConsentModal() {
467484
variant="primary"
468485
className="flex-1"
469486
onClick={handleApprove}
470-
disabled={isProcessing}
487+
disabled={isProcessing || !approveReady}
471488
>
472489
{isProcessing ? (
473490
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
474491
) : (
475492
<Check className="h-4 w-4 mr-2" />
476493
)}
477-
Approve
494+
{approveReady ? 'Approve' : 'Approve (wait...)'}
478495
</Button>
479496
</div>
480497

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ pub struct PendingAuthorization {
136136
pub code_challenge_method: Option<String>,
137137
/// Unix timestamp when this request expires
138138
pub expires_at: i64,
139+
/// Consent token: cryptographic secret shared only via Tauri IPC.
140+
/// Prevents any process from approving consent via HTTP without going
141+
/// through the desktop app UI. Only present on initial consent requests
142+
/// (not on auth-code entries used for token exchange).
143+
pub consent_token: Option<String>,
139144
}
140145

141146
/// OAuth authorization endpoint
@@ -272,6 +277,18 @@ pub async fn oauth_authorize(
272277
.map(|d| d.as_secs() as i64 + 300) // 5 minutes
273278
.unwrap_or(i64::MAX);
274279

280+
// Generate consent_token: a cryptographic secret shared only via Tauri IPC.
281+
// This prevents any external process from approving consent by calling an
282+
// HTTP endpoint directly—only the desktop app UI that retrieves this token
283+
// via get_pending_consent can submit a valid approval.
284+
let consent_token = {
285+
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
286+
let mut bytes = [0u8; 32];
287+
use rand::RngCore;
288+
rand::thread_rng().fill_bytes(&mut bytes);
289+
URL_SAFE_NO_PAD.encode(bytes)
290+
};
291+
275292
{
276293
let mut gateway_state = state.write().await;
277294
gateway_state.store_pending_authorization(
@@ -285,6 +302,7 @@ pub async fn oauth_authorize(
285302
code_challenge: params.code_challenge.clone(),
286303
code_challenge_method: params.code_challenge_method.clone(),
287304
expires_at,
305+
consent_token: Some(consent_token),
288306
},
289307
);
290308
}
@@ -850,6 +868,7 @@ pub async fn oauth_consent_approve(
850868
code_challenge: pending.code_challenge.clone(),
851869
code_challenge_method: pending.code_challenge_method.clone(),
852870
expires_at: code_expires_at,
871+
consent_token: None, // Auth code entries don't need consent tokens
853872
},
854873
);
855874

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
mod dependencies;
88
mod handlers;
99
pub mod logging_middleware;
10+
pub mod rate_limit;
1011
mod service_container;
1112
mod startup;
1213
mod state;
@@ -301,10 +302,10 @@ impl GatewayServer {
301302
// Fallback for clients that don't fetch metadata (VS Code default behavior)
302303
.route("/authorize", get(handlers::oauth_authorize))
303304
.route("/oauth/token", post(handlers::oauth_token))
304-
.route(
305-
"/oauth/consent/approve",
306-
post(handlers::oauth_consent_approve),
307-
)
305+
// NOTE: /oauth/consent/approve was removed for security.
306+
// Consent approval now happens exclusively via Tauri IPC command
307+
// (approve_oauth_consent), which can only be invoked by the desktop
308+
// app's own WebView—not by external HTTP clients, scripts, or bots.
308309
// Client registration (DCR - public)
309310
.route("/oauth/register", post(handlers::oauth_register))
310311
// Client management (for desktop app)
@@ -317,7 +318,22 @@ impl GatewayServer {
317318
.route(
318319
"/oauth/clients/{client_id}",
319320
delete(handlers::oauth_delete_client),
320-
)
321+
);
322+
323+
// E2E test mode: re-enable HTTP consent endpoint (guarded by env var).
324+
// In production this endpoint does NOT exist—consent is Tauri-IPC-only.
325+
if std::env::var("MCPMUX_E2E_TEST").is_ok() {
326+
warn!("[Gateway] E2E test mode: /oauth/consent/approve HTTP endpoint enabled");
327+
router = router.route(
328+
"/oauth/consent/approve",
329+
post(handlers::oauth_consent_approve),
330+
);
331+
}
332+
333+
// Rate limiter for OAuth endpoints (prevents abuse / consent flooding)
334+
let rate_limiter = rate_limit::default_oauth_rate_limiter();
335+
336+
let mut router = router
321337
// Protected MCP routes (using rmcp's StreamableHttpService)
322338
.merge(mcp_routes)
323339
// Client features (needs services)
@@ -328,7 +344,10 @@ impl GatewayServer {
328344
// Request/Response logging with body (DEBUG level)
329345
.layer(middleware::from_fn(
330346
logging_middleware::http_logging_middleware,
331-
));
347+
))
348+
// Rate limiting on OAuth endpoints
349+
.layer(axum::Extension(rate_limiter))
350+
.layer(middleware::from_fn(rate_limit::rate_limit_middleware));
332351

333352
// Add CORS if enabled
334353
if self.config.enable_cors {

0 commit comments

Comments
 (0)