From 37c0158765def5ce240036ad407778cd404ff88a Mon Sep 17 00:00:00 2001 From: Nitanshu Lokhande <56120084+nlok5923@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:09:47 +0530 Subject: [PATCH] Fix: allow OAuth callback to work on remote servers (fixes #186) (#212) * fix: allow OAuth callback to work on remote servers via OAUTH_CALLBACK_HOST Fixes #186. The OAuth callback URL was hardcoded to `http://127.0.0.1:9876` in two places (NEAR AI login and MCP server auth). On a remote server this URL is unreachable from the user's browser, making authentication impossible. Changes: - Add `callback_host()` to `oauth_defaults` that reads `OAUTH_CALLBACK_HOST` (default: `127.0.0.1`) - Update `bind_callback_listener()` to bind to `0.0.0.0` when a non-loopback host is configured, so the port is reachable from outside the machine - Update `session.rs` and `mcp/auth.rs` to use `callback_host()` instead of hardcoded `127.0.0.1` / `localhost` Usage on a remote server: export OAUTH_CALLBACK_HOST= ironclaw login * fix: address PR review comments for OAuth callback security * fix: address serrrfirat review comments on PR #212 --------- Co-authored-by: firat.sertgoz --- src/cli/oauth_defaults.rs | 176 +++++++++++++++++++++++++++++++------- src/llm/session.rs | 15 ++++ src/tools/mcp/auth.rs | 13 ++- 3 files changed, 172 insertions(+), 32 deletions(-) diff --git a/src/cli/oauth_defaults.rs b/src/cli/oauth_defaults.rs index bd4ff640..7a4586b9 100644 --- a/src/cli/oauth_defaults.rs +++ b/src/cli/oauth_defaults.rs @@ -66,12 +66,47 @@ pub const OAUTH_CALLBACK_PORT: u16 = 9876; /// /// Checks `IRONCLAW_OAUTH_CALLBACK_URL` env var first (useful for remote/VPS /// deployments where `127.0.0.1` is unreachable from the user's browser), -/// then falls back to `http://127.0.0.1:{OAUTH_CALLBACK_PORT}`. +/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`. pub fn callback_url() -> String { std::env::var("IRONCLAW_OAUTH_CALLBACK_URL") .ok() .filter(|v| !v.is_empty()) - .unwrap_or_else(|| format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT)) + .unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT)) +} + +/// Returns the hostname used in OAuth callback URLs. +/// +/// Reads `OAUTH_CALLBACK_HOST` from the environment (default: `127.0.0.1`). +/// +/// **Remote server usage:** set `OAUTH_CALLBACK_HOST` to the network interface +/// address you want to listen on (e.g. the server's LAN IP or `0.0.0.0`). +/// The callback listener will bind to that specific address instead of the +/// loopback interface, so the OAuth redirect can reach an external browser. +/// Note: this transmits the session token over plain HTTP — prefer SSH port +/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible. +/// +/// # Example +/// +/// ```bash +/// export OAUTH_CALLBACK_HOST=203.0.113.10 +/// ironclaw login +/// # Opens: http://203.0.113.10:9876/auth/callback +/// ``` +pub fn callback_host() -> String { + std::env::var("OAUTH_CALLBACK_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()) +} + +/// Returns `true` if `host` is a loopback address that only accepts local connections. +/// +/// Covers `localhost` (case-insensitive), the full `127.0.0.0/8` IPv4 loopback +/// range, and `::1` for IPv6. +pub fn is_loopback_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) } /// Error from the OAuth callback listener. @@ -90,35 +125,50 @@ pub enum OAuthCallbackError { Io(String), } +/// Map a `std::io::Error` from a bind attempt to an `OAuthCallbackError`. +fn bind_error(e: std::io::Error) -> OAuthCallbackError { + if e.kind() == std::io::ErrorKind::AddrInUse { + OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string()) + } else { + OAuthCallbackError::Io(e.to_string()) + } +} + /// Bind the OAuth callback listener on the fixed port. /// -/// Binds to IPv4 `127.0.0.1` first because callback URLs use `127.0.0.1` -/// explicitly (e.g., NEAR AI redirects to `http://127.0.0.1:9876/auth/callback`). -/// Falls back to IPv6 `[::1]` only if IPv4 binding fails for a reason other -/// than `AddrInUse`. If the port is already occupied, fails immediately. +/// When `OAUTH_CALLBACK_HOST` is a loopback address (the default `127.0.0.1`), +/// binds to `127.0.0.1` first and falls back to `[::1]` so local-only auth +/// flows remain restricted to the local machine. +/// +/// When `OAUTH_CALLBACK_HOST` is set to a remote address, binds to that +/// specific address so only connections directed to it are accepted. pub async fn bind_callback_listener() -> Result { - let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT); - match TcpListener::bind(&ipv4_addr).await { - Ok(listener) => return Ok(listener), - Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { - return Err(OAuthCallbackError::PortInUse( - OAUTH_CALLBACK_PORT, - e.to_string(), - )); - } - Err(_) => { - // IPv4 not available, fall back to IPv6 - } - } - TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT)) - .await - .map_err(|e| { - if e.kind() == std::io::ErrorKind::AddrInUse { - OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string()) - } else { - OAuthCallbackError::Io(e.to_string()) + let host = callback_host(); + + if is_loopback_host(&host) { + // Local mode: prefer IPv4 loopback, fall back to IPv6. + let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT); + match TcpListener::bind(&ipv4_addr).await { + Ok(listener) => return Ok(listener), + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { + return Err(OAuthCallbackError::PortInUse( + OAUTH_CALLBACK_PORT, + e.to_string(), + )); } - }) + Err(_) => { + // IPv4 not available, fall back to IPv6 + } + } + TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT)) + .await + .map_err(bind_error) + } else { + // Remote mode: bind to the specific configured host address only, + // not 0.0.0.0, to limit exposure to the intended interface. + let addr = format!("{}:{}", host, OAUTH_CALLBACK_PORT); + TcpListener::bind(&addr).await.map_err(bind_error) + } } /// Wait for an OAuth callback and extract a query parameter value. @@ -311,27 +361,91 @@ pub fn landing_html(provider_name: &str, success: bool) -> String { mod tests { use std::sync::Mutex; - use crate::cli::oauth_defaults::{builtin_credentials, callback_url, landing_html}; + use crate::cli::oauth_defaults::{ + builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html, + }; /// Serializes env-mutating tests to prevent parallel races. static ENV_MUTEX: Mutex<()> = Mutex::new(()); + #[test] + fn test_is_loopback_host() { + assert!(is_loopback_host("127.0.0.1")); + assert!(is_loopback_host("127.0.0.2")); // full 127.0.0.0/8 range + assert!(is_loopback_host("127.255.255.254")); + assert!(is_loopback_host("::1")); + assert!(is_loopback_host("localhost")); + assert!(is_loopback_host("LOCALHOST")); + assert!(!is_loopback_host("203.0.113.10")); + assert!(!is_loopback_host("my-server.example.com")); + assert!(!is_loopback_host("0.0.0.0")); + } + + #[test] + fn test_callback_host_default() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + let original = std::env::var("OAUTH_CALLBACK_HOST").ok(); + // SAFETY: Under ENV_MUTEX, no concurrent env access. + unsafe { + std::env::remove_var("OAUTH_CALLBACK_HOST"); + } + assert_eq!(callback_host(), "127.0.0.1"); + // Restore + unsafe { + if let Some(val) = original { + std::env::set_var("OAUTH_CALLBACK_HOST", val); + } + } + } + + #[test] + fn test_callback_host_env_override() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok(); + let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok(); + // SAFETY: Under ENV_MUTEX, no concurrent env access. + unsafe { + std::env::set_var("OAUTH_CALLBACK_HOST", "203.0.113.10"); + std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL"); + } + assert_eq!(callback_host(), "203.0.113.10"); + // callback_url() fallback should incorporate the custom host + let url = callback_url(); + assert!(url.contains("203.0.113.10"), "url was: {url}"); + // Restore + unsafe { + if let Some(val) = original_host { + std::env::set_var("OAUTH_CALLBACK_HOST", val); + } else { + std::env::remove_var("OAUTH_CALLBACK_HOST"); + } + if let Some(val) = original_url { + std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val); + } + } + } + #[test] fn test_callback_url_default() { let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); - // Clear the env var to test default behavior - let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok(); + // Clear both env vars to test default behavior + let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok(); + let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok(); // SAFETY: Under ENV_MUTEX, no concurrent env access. unsafe { std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL"); + std::env::remove_var("OAUTH_CALLBACK_HOST"); } let url = callback_url(); assert_eq!(url, "http://127.0.0.1:9876"); // Restore unsafe { - if let Some(val) = original { + if let Some(val) = original_url { std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val); } + if let Some(val) = original_host { + std::env::set_var("OAUTH_CALLBACK_HOST", val); + } } } diff --git a/src/llm/session.rs b/src/llm/session.rs index f6f9a54b..ac4539b3 100644 --- a/src/llm/session.rs +++ b/src/llm/session.rs @@ -7,6 +7,8 @@ use std::path::PathBuf; use std::sync::Arc; +use crate::cli::oauth_defaults::OAUTH_CALLBACK_PORT; + use chrono::{DateTime, Utc}; use reqwest::Client; use secrecy::SecretString; @@ -238,6 +240,7 @@ impl SessionManager { use crate::cli::oauth_defaults; let cb_url = oauth_defaults::callback_url(); + let host = oauth_defaults::callback_host(); // Show auth provider menu BEFORE binding the listener println!(); @@ -288,6 +291,18 @@ impl SessionManager { } } + // Warn about plain-HTTP token transmission only for OAuth paths (1, 2) + // where the callback URL actually carries the session token. + if !oauth_defaults::is_loopback_host(&host) { + println!(); + println!("Warning: OAuth callback is using plain HTTP to a remote host ({host})."); + println!(" The session token will be transmitted unencrypted."); + println!(" Consider SSH port forwarding instead:"); + println!( + " ssh -L {OAUTH_CALLBACK_PORT}:127.0.0.1:{OAUTH_CALLBACK_PORT} user@{host}" + ); + } + // OAuth paths: bind the callback listener now let listener = oauth_defaults::bind_callback_listener() .await diff --git a/src/tools/mcp/auth.rs b/src/tools/mcp/auth.rs index 1a31dd3b..98a6a62b 100644 --- a/src/tools/mcp/auth.rs +++ b/src/tools/mcp/auth.rs @@ -380,7 +380,18 @@ pub async fn authorize_mcp_server( ) -> Result { // Find an available port for the callback first (needed for DCR) let (listener, port) = find_available_port().await?; - let redirect_uri = format!("http://localhost:{}/callback", port); + let host = oauth_defaults::callback_host(); + let redirect_uri = format!("http://{}:{}/callback", host, port); + + // Warn when the callback is served over plain HTTP to a remote host. + // Authorization codes travel unencrypted; SSH port forwarding is safer: + // ssh -L :127.0.0.1: user@your-server + if !oauth_defaults::is_loopback_host(&host) { + println!("Warning: MCP OAuth callback is using plain HTTP to a remote host ({host})."); + println!(" Authorization codes will be transmitted unencrypted."); + println!(" Consider SSH port forwarding instead:"); + println!(" ssh -L {port}:127.0.0.1:{port} user@{host}"); + } // Determine client_id and endpoints let (client_id, authorization_url, token_url, use_pkce, scopes, extra_params) =