[RIP-SEC] MCP OAuth login opens the server-supplied `authorization_endpoint` with `webbrowser::open` and no URL-scheme allowlist

Open 💬 1 comment Opened Aug 5, 2026 by scadastrangelove

Summary

During MCP OAuth login, Codex builds an authorization URL from the OAuth metadata's
authorization_endpoint — a value served by the (untrusted) MCP server — and passes it to
webbrowser::open with no scheme validation. A malicious or MITM'd MCP server can set
authorization_endpoint to a non-web scheme (file:, smb:, search-ms:, a custom app://), and
Codex hands that URL to the victim's OS default handler.

Where

  • codex-rs/rmcp-client/src/perform_oauth_login.rswebbrowser::open(auth_url) (no scheme check);

auth_url is built from oauth_state.get_authorization_url() whose base is the server-supplied
authorization_endpoint.

  • Reachable via codex mcp login <server> and the automatic skill-MCP-dependency install path

(launch_browser = true).

  • Present on current main.

Reproduction

Standing up a local OAuth-metadata server whose authorization_endpoint is a dangerous-scheme URL and
running the real start_authorization + get_authorization_url shows the attacker scheme survives
intact into the string passed to webbrowser::open (verified without invoking webbrowser::open):

| server authorization_endpoint | resulting authorization_url scheme |
|---|---|
| file:///etc/passwd | file |
| smb://attacker.example/share | smb |
| calculatorapp://run?x=1 | calculatorapp |
| search-ms:query=secret&crumb=location | search-ms |

Neither codex-rs nor the vendored rmcp URL construction rejects the scheme.

Impact

A malicious/MITM MCP server can cause Codex to invoke the victim's OS handler for an arbitrary URL
scheme (e.g. search-ms: opens Explorer search on Windows; smb: can trigger an outbound
SMB/NTLM connection; a registered custom handler is launched). Protocol-handler drive-by, not RCE;
requires the victim to add/trust the server or install a skill that pulls it in.

Suggested fix

Validate the scheme against an allowlist (https; http only for loopback) before webbrowser::open,
and reject non-web schemes when accepting authorization_endpoint from discovery.

---
Found with the rust-in-peace pipeline
(AI-assisted Rust vulnerability research).

View original on GitHub ↗

1 Comment

scadastrangelove · 21 days ago

Candidate fix (with a regression test) for this issue. I can't open a PR directly —
openai/codex restricts pull requests to collaborators — so the patch is inline below,
and also on a branch you can pull/cherry-pick: https://github.com/scadastrangelove/codex/tree/codex-mcp-oauth-scheme-allowlist

<details><summary>Patch (git diff)</summary>

diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs
index 4178464..b45baff 100644
--- a/codex-rs/rmcp-client/src/perform_oauth_login.rs
+++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs
@@ -576,6 +576,10 @@ impl OauthLoginFlow {
     }
 
     async fn finish(mut self, emit_browser_url: bool) -> Result<()> {
+        // Reject dangerous URI schemes before the URL is opened in (or printed
+        // for) the user's browser — the endpoint originates from the untrusted
+        // MCP server's metadata.
+        ensure_browsable_auth_url(&self.auth_url)?;
         if self.launch_browser {
             let server_name = &self.server_name;
             let auth_url = &self.auth_url;
@@ -719,6 +723,24 @@ fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
     format!("{url}{separator}{key}={encoded}")
 }
 
+/// The OAuth authorization endpoint is taken from the (untrusted) MCP server's
+/// advertised metadata, and the resulting URL is later handed to the system
+/// browser via `webbrowser::open`. Only ever open `http`/`https` URLs: a server
+/// that advertises an endpoint like `file:`, `smb:`, or a custom app scheme
+/// (e.g. `calculatorapp:`, `search-ms:`) could otherwise trigger a drive-by
+/// launch of an arbitrary URI-scheme handler on the user's machine.
+fn ensure_browsable_auth_url(auth_url: &str) -> Result<()> {
+    let parsed = Url::parse(auth_url)
+        .map_err(|err| anyhow!("invalid authorization URL from MCP server: {err}"))?;
+    match parsed.scheme() {
+        "http" | "https" => Ok(()),
+        other => bail!(
+            "refusing to open authorization URL with unsupported scheme {other:?}; \
+             expected http or https"
+        ),
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use std::sync::Arc;
@@ -759,6 +781,32 @@ mod tests {
     use super::perform_oauth_login_silent;
     use super::start_authorization;
 
+    #[test]
+    fn ensure_browsable_auth_url_rejects_dangerous_schemes() {
+        // Legitimate OAuth authorization endpoints are http(s).
+        assert!(
+            super::ensure_browsable_auth_url("https://idp.example.com/authorize?client_id=x")
+                .is_ok()
+        );
+        assert!(super::ensure_browsable_auth_url("http://127.0.0.1:8976/authorize").is_ok());
+
+        // A malicious MCP server must not be able to drive a drive-by launch of
+        // an arbitrary URI-scheme handler via its advertised authorization URL.
+        for dangerous in [
+            "file:///etc/passwd",
+            "smb://attacker/share",
+            "calculatorapp:payload",
+            "search-ms:query=malware",
+            "javascript:alert(1)",
+            "not-a-valid-url",
+        ] {
+            assert!(
+                super::ensure_browsable_auth_url(dangerous).is_err(),
+                "expected scheme to be rejected: {dangerous}"
+            );
+        }
+    }
+
     #[derive(Default)]
     struct RecordingHttpClient {
         requests: AtomicUsize,

</details>

Built and tested on main @c87a218 with toolchain 1.95.0.
_Found with the rust-in-peace pipeline._