Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
177 changes: 170 additions & 7 deletions MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Net;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
Expand Down Expand Up @@ -51,15 +52,15 @@ 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);
}

/// <summary>
/// Saves a user-provided URL to the local HTTP pref.
/// </summary>
public static void SaveLocalBaseUrl(string userValue)
{
string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl);
string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl, remoteScope: false);
EditorPrefs.SetString(LocalPrefKey, normalized);
}

Expand All @@ -74,7 +75,7 @@ public static string GetRemoteBaseUrl()
{
return DefaultRemoteBaseUrl;
}
return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl);
return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl, remoteScope: true);
}

/// <summary>
Expand All @@ -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);
}

Expand Down Expand Up @@ -146,10 +147,169 @@ public static ConfiguredTransport GetCurrentServerTransport()
return IsRemoteScope() ? ConfiguredTransport.HttpRemote : ConfiguredTransport.Http;
}

/// <summary>
/// Returns true when advanced settings allow binding HTTP Local to all interfaces
/// (e.g. 0.0.0.0 / ::). Disabled by default.
/// </summary>
public static bool AllowLanHttpBind()
{
return EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false);
}

/// <summary>
/// Returns true when advanced settings allow insecure HTTP/WS for remote endpoints.
/// Disabled by default.
/// </summary>
public static bool AllowInsecureRemoteHttp()
{
return EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
}

/// <summary>
/// Returns true if the host is loopback-only.
/// </summary>
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;
}

/// <summary>
/// Returns true if the host is a bind-all-interfaces address.
/// </summary>
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;
}

/// <summary>
/// Returns true when the URL host is acceptable for HTTP Local launch.
/// Loopback is always allowed. Bind-all interfaces requires explicit opt-in.
/// </summary>
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;
}

/// <summary>
/// Returns true when remote URL is allowed by current security policy.
/// HTTPS is required by default; HTTP needs explicit opt-in.
/// </summary>
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;
}

/// <summary>
/// Returns true when the currently configured remote URL satisfies security policy.
/// </summary>
public static bool IsCurrentRemoteUrlAllowed(out string error)
{
return IsRemoteUrlAllowed(GetRemoteBaseUrl(), out error);
}

/// <summary>
/// Human-readable host requirement for HTTP Local based on current security settings.
/// </summary>
public static string GetHttpLocalHostRequirementText()
{
return AllowLanHttpBind()
? "localhost/127.0.0.1/::1/0.0.0.0/::"
: "localhost/127.0.0.1/::1";
}

/// <summary>
/// Normalizes a URL so that we consistently store just the base (no trailing slash/path).
/// </summary>
private static string NormalizeBaseUrl(string value, string defaultUrl)
private static string NormalizeBaseUrl(string value, string defaultUrl, bool remoteScope)
{
if (string.IsNullOrWhiteSpace(value))
{
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion MCPForUnity/Editor/Services/IServerManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public interface IServerManagementService
/// <summary>
/// Check if the local HTTP server can be started
/// </summary>
/// <returns>True if HTTP transport is enabled and URL is local</returns>
/// <returns>True if HTTP transport is enabled and URL satisfies local launch security policy</returns>
bool CanStartLocalServer();
}
}
24 changes: 4 additions & 20 deletions MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -129,23 +131,5 @@ public string QuoteIfNeeded(string input)
return input.IndexOf(' ') >= 0 ? $"\"{input}\"" : input;
}

/// <summary>
/// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0, ::1)
/// </summary>
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;
}
}
}
}
44 changes: 35 additions & 9 deletions MCPForUnity/Editor/Services/ServerManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -875,7 +894,8 @@ public bool IsLocalUrl()
}

/// <summary>
/// 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.
/// </summary>
private static bool IsLocalUrl(string url)
{
Expand All @@ -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
{
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ public async Task<bool> 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))
Expand Down
Loading