Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
166 changes: 159 additions & 7 deletions MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,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 +74,7 @@ public static string GetRemoteBaseUrl()
{
return DefaultRemoteBaseUrl;
}
return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl);
return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl, remoteScope: true);
}

/// <summary>
Expand All @@ -87,7 +87,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 +146,159 @@ 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();
return normalized == "localhost"
|| normalized == "127.0.0.1"
|| normalized == "::1"
|| normalized == "0:0:0:0:0:0:0:1";
}

/// <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();
return normalized == "0.0.0.0"
|| normalized == "::"
|| normalized == "0:0:0:0:0:0:0:0";
}

/// <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 +307,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