diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 71a5be703..c5dab7c41 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -30,6 +30,8 @@ internal static class EditorPrefKeys internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh"; internal const string ProjectScopedToolsLocalHttp = "MCPForUnity.ProjectScopedTools.LocalHttp"; + internal const string AllowLanHttpBind = "MCPForUnity.Security.AllowLanHttpBind"; + internal const string AllowInsecureRemoteHttp = "MCPForUnity.Security.AllowInsecureRemoteHttp"; internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath"; internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath"; diff --git a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs index 98fdb2d5a..4f8064612 100644 --- a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; @@ -51,7 +52,7 @@ public static void SaveBaseUrl(string userValue) public static string GetLocalBaseUrl() { string stored = EditorPrefs.GetString(LocalPrefKey, DefaultLocalBaseUrl); - return NormalizeBaseUrl(stored, DefaultLocalBaseUrl); + return NormalizeBaseUrl(stored, DefaultLocalBaseUrl, remoteScope: false); } /// @@ -59,7 +60,7 @@ public static string GetLocalBaseUrl() /// public static void SaveLocalBaseUrl(string userValue) { - string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl); + string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl, remoteScope: false); EditorPrefs.SetString(LocalPrefKey, normalized); } @@ -74,7 +75,7 @@ public static string GetRemoteBaseUrl() { return DefaultRemoteBaseUrl; } - return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl); + return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl, remoteScope: true); } /// @@ -87,7 +88,7 @@ public static void SaveRemoteBaseUrl(string userValue) EditorPrefs.SetString(RemotePrefKey, DefaultRemoteBaseUrl); return; } - string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl); + string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl, remoteScope: true); EditorPrefs.SetString(RemotePrefKey, normalized); } @@ -146,10 +147,169 @@ public static ConfiguredTransport GetCurrentServerTransport() return IsRemoteScope() ? ConfiguredTransport.HttpRemote : ConfiguredTransport.Http; } + /// + /// Returns true when advanced settings allow binding HTTP Local to all interfaces + /// (e.g. 0.0.0.0 / ::). Disabled by default. + /// + public static bool AllowLanHttpBind() + { + return EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false); + } + + /// + /// Returns true when advanced settings allow insecure HTTP/WS for remote endpoints. + /// Disabled by default. + /// + public static bool AllowInsecureRemoteHttp() + { + return EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false); + } + + /// + /// Returns true if the host is loopback-only. + /// + public static bool IsLoopbackHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + string normalized = host.Trim().Trim('[', ']').ToLowerInvariant(); + if (normalized == "localhost") + { + return true; + } + + if (IPAddress.TryParse(normalized, out IPAddress parsedIp)) + { + return IPAddress.IsLoopback(parsedIp); + } + + return false; + } + + /// + /// Returns true if the host is a bind-all-interfaces address. + /// + public static bool IsBindAllInterfacesHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + string normalized = host.Trim().Trim('[', ']').ToLowerInvariant(); + if (IPAddress.TryParse(normalized, out IPAddress parsedIp)) + { + return parsedIp.Equals(IPAddress.Any) || parsedIp.Equals(IPAddress.IPv6Any); + } + + return false; + } + + /// + /// Returns true when the URL host is acceptable for HTTP Local launch. + /// Loopback is always allowed. Bind-all interfaces requires explicit opt-in. + /// + public static bool IsHttpLocalUrlAllowedForLaunch(string url, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(url)) + { + error = "HTTP Local requires a loopback URL (localhost/127.0.0.1/::1)."; + return false; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + error = $"Invalid URL: {url}"; + return false; + } + + string host = uri.Host; + if (IsLoopbackHost(host)) + { + return true; + } + + if (IsBindAllInterfacesHost(host)) + { + if (AllowLanHttpBind()) + { + return true; + } + + error = "Binding to all interfaces (0.0.0.0/::) is disabled by default. " + + "Enable \"Allow LAN bind for HTTP Local\" in Advanced Settings to opt in."; + return false; + } + + error = "HTTP Local requires a loopback URL (localhost/127.0.0.1/::1)."; + return false; + } + + /// + /// Returns true when remote URL is allowed by current security policy. + /// HTTPS is required by default; HTTP needs explicit opt-in. + /// + public static bool IsRemoteUrlAllowed(string remoteBaseUrl, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(remoteBaseUrl)) + { + error = "HTTP Remote requires a configured URL."; + return false; + } + + if (!Uri.TryCreate(remoteBaseUrl, UriKind.Absolute, out var uri)) + { + error = $"Invalid HTTP Remote URL: {remoteBaseUrl}"; + return false; + } + + if (uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) + { + if (AllowInsecureRemoteHttp()) + { + return true; + } + + error = "HTTP Remote requires HTTPS by default. Enable \"Allow insecure HTTP for HTTP Remote\" in Advanced Settings to opt in."; + return false; + } + + error = $"Unsupported URL scheme '{uri.Scheme}'. Use https:// (or http:// only with explicit insecure opt-in)."; + return false; + } + + /// + /// Returns true when the currently configured remote URL satisfies security policy. + /// + public static bool IsCurrentRemoteUrlAllowed(out string error) + { + return IsRemoteUrlAllowed(GetRemoteBaseUrl(), out error); + } + + /// + /// Human-readable host requirement for HTTP Local based on current security settings. + /// + public static string GetHttpLocalHostRequirementText() + { + return AllowLanHttpBind() + ? "localhost/127.0.0.1/::1/0.0.0.0/::" + : "localhost/127.0.0.1/::1"; + } + /// /// Normalizes a URL so that we consistently store just the base (no trailing slash/path). /// - private static string NormalizeBaseUrl(string value, string defaultUrl) + private static string NormalizeBaseUrl(string value, string defaultUrl, bool remoteScope) { if (string.IsNullOrWhiteSpace(value)) { @@ -158,10 +318,13 @@ private static string NormalizeBaseUrl(string value, string defaultUrl) string trimmed = value.Trim(); - // Ensure scheme exists; default to http:// if user omitted it. + // Ensure scheme exists. + // For HTTP Remote, default to https:// to avoid accidental plaintext transport. + // For HTTP Local, default to http:// for zero-friction local setup. if (!trimmed.Contains("://")) { - trimmed = $"http://{trimmed}"; + string defaultScheme = remoteScope ? "https" : "http"; + trimmed = $"{defaultScheme}://{trimmed}"; } // Remove trailing slash segments. diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs index 299fad511..2da66738f 100644 --- a/MCPForUnity/Editor/Services/IServerManagementService.cs +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs @@ -58,7 +58,7 @@ public interface IServerManagementService /// /// Check if the local HTTP server can be started /// - /// True if HTTP transport is enabled and URL is local + /// True if HTTP transport is enabled and URL satisfies local launch security policy bool CanStartLocalServer(); } } diff --git a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs index 47b46755b..47ba7186d 100644 --- a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs +++ b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs @@ -31,9 +31,11 @@ public bool TryBuildCommand(out string fileName, out string arguments, out strin } string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); - if (!IsLocalUrl(httpUrl)) + if (!HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(httpUrl, out string localUrlError)) { - error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost."; + error = string.IsNullOrEmpty(localUrlError) + ? $"The configured URL ({httpUrl}) is not allowed for HTTP Local launch." + : $"{localUrlError} (configured URL: {httpUrl})"; return false; } @@ -129,23 +131,5 @@ public string QuoteIfNeeded(string input) return input.IndexOf(' ') >= 0 ? $"\"{input}\"" : input; } - /// - /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0, ::1) - /// - private static bool IsLocalUrl(string url) - { - if (string.IsNullOrEmpty(url)) return false; - - try - { - var uri = new Uri(url); - string host = uri.Host.ToLower(); - return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; - } - catch - { - return false; - } - } } } diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index ecd248da5..0b7799fad 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -654,13 +654,32 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b } return false; } - if (!quiet) + + // If the pidfile PID is no longer the active listener, treat handshake state as stale + // and continue with guarded port-based heuristics below. + if (!pidIsListener) { - McpLog.Warn( - $"Refusing to stop port {port}: pidfile PID {pidFromFile} failed validation " + - $"(listener={pidIsListener}, tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk})."); + if (!quiet) + { + McpLog.Warn( + $"Stale pidfile for port {port}: pidfile PID {pidFromFile} is not the current listener " + + $"(tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk}). Falling back to guarded port heuristics."); + } + try { DeletePidFile(pidFilePath); } catch { } + ClearLocalServerPidTracking(); + } + else + { + // PID still owns the listener, but identity validation failed. + // Fail closed to avoid terminating unrelated processes. + if (!quiet) + { + McpLog.Warn( + $"Refusing to stop port {port}: pidfile PID {pidFromFile} failed validation " + + $"(listener={pidIsListener}, tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk})."); + } + return false; } - return false; } } } @@ -875,7 +894,8 @@ public bool IsLocalUrl() } /// - /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0) + /// Check if a URL is local or bind-all (localhost/loopback and 0.0.0.0/::). + /// This helper is intentionally broader than local-launch policy checks. /// private static bool IsLocalUrl(string url) { @@ -884,8 +904,8 @@ private static bool IsLocalUrl(string url) try { var uri = new Uri(url); - string host = uri.Host.ToLower(); - return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; + string host = uri.Host; + return HttpEndpointUtility.IsLoopbackHost(host) || HttpEndpointUtility.IsBindAllInterfacesHost(host); } catch { @@ -899,7 +919,13 @@ private static bool IsLocalUrl(string url) public bool CanStartLocalServer() { bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; - return useHttpTransport && IsLocalUrl(); + if (!useHttpTransport) + { + return false; + } + + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); + return HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(httpUrl, out _); } private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command) diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs index 668c20ad2..8832b3227 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -86,6 +86,15 @@ public async Task StartAsync() ? EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty) : string.Empty; + if (HttpEndpointUtility.IsRemoteScope() + && !HttpEndpointUtility.IsCurrentRemoteUrlAllowed(out string remoteUrlError)) + { + string message = remoteUrlError ?? "HTTP Remote URL is not allowed by current security settings."; + _state = TransportState.Disconnected(TransportDisplayName, message); + McpLog.Error($"[WebSocket] {message}"); + return false; + } + // Get project root path (strip /Assets from dataPath) for focus nudging string dataPath = Application.dataPath; if (!string.IsNullOrEmpty(dataPath)) diff --git a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs index 386f8ac59..218e95e17 100644 --- a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs @@ -26,6 +26,8 @@ public class McpAdvancedSection private Button clearGitUrlButton; private Toggle debugLogsToggle; private Toggle devModeForceRefreshToggle; + private Toggle allowLanHttpBindToggle; + private Toggle allowInsecureRemoteHttpToggle; private TextField deploySourcePath; private Button browseDeploySourceButton; private Button clearDeploySourceButton; @@ -64,6 +66,8 @@ private void CacheUIElements() clearGitUrlButton = Root.Q