using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; namespace YMhut.Box.Core.Plugins; public static class PluginNetworkPolicy { public static bool TryNormalizePublicOrigin(string? value, bool allowWebSocket, out string origin) { origin = string.Empty; if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || uri.AbsolutePath != "/" || (uri.Scheme != Uri.UriSchemeHttps && (!allowWebSocket || uri.Scheme != "wss")) || !IsPublicHostName(uri.Host)) { return false; } origin = uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant(); return true; } public static bool IsAllowed(Uri uri, IReadOnlyList? declaredOrigins, bool allowWebSocket = false) { if (!TryNormalizePublicOrigin(uri.GetLeftPart(UriPartial.Authority), allowWebSocket, out var candidate)) { return false; } return (declaredOrigins ?? []).Any(value => TryNormalizePublicOrigin(value, allowWebSocket, out var allowed) && string.Equals(candidate, allowed, StringComparison.OrdinalIgnoreCase)); } public static async Task ResolvesToPublicAddressAsync(string host, CancellationToken cancellationToken = default) { if (!IsPublicHostName(host)) { return false; } if (IPAddress.TryParse(host, out var literal)) { return IsPublicAddress(literal); } try { var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false); return addresses.Length > 0 && addresses.All(IsPublicAddress); } catch (SocketException) { return false; } } public static bool IsPublicHostName(string host) { if (string.IsNullOrWhiteSpace(host) || string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase) || host.EndsWith(".local", StringComparison.OrdinalIgnoreCase) || host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase) || !host.Contains('.') && !IPAddress.TryParse(host, out _)) { return false; } return !IPAddress.TryParse(host, out var address) || IsPublicAddress(address); } public static bool IsPublicAddress(IPAddress address) { if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) || address.Equals(IPAddress.None) || address.Equals(IPAddress.IPv6None)) { return false; } if (address.AddressFamily == AddressFamily.InterNetworkV6) { return !address.IsIPv6LinkLocal && !address.IsIPv6SiteLocal && !address.IsIPv6Multicast && !(address.GetAddressBytes()[0] is 0xFC or 0xFD); } var bytes = address.GetAddressBytes(); return bytes[0] != 0 && bytes[0] != 10 && bytes[0] != 127 && !(bytes[0] == 100 && bytes[1] is >= 64 and <= 127) && !(bytes[0] == 169 && bytes[1] == 254) && !(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) && !(bytes[0] == 192 && bytes[1] == 168) && !(bytes[0] == 198 && bytes[1] is 18 or 19) && bytes[0] < 224; } } public static class PluginWebOrigin { public static string Create(string pluginId, string surfaceId) { var value = $"{PluginIds.Normalize(pluginId)}:{PluginIds.Normalize(surfaceId)}"; var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant()[..32]; return $"https://p-{hash}.plugin.ymhut.invalid"; } } public static class PluginExternalWebOrigin { public static string ProtocolName(string pluginId, string surfaceId) { var value = $"external:{PluginIds.Normalize(pluginId)}:{PluginIds.Normalize(surfaceId)}"; var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant()[..32]; return $"p-{hash}"; } public static string Create(string pluginId, string surfaceId) { return $"https://{ProtocolName(pluginId, surfaceId)}.localhost"; } } public static class PluginResourcePathPolicy { public static bool IsSafeRequestUri(Uri uri) { var original = uri.OriginalString; var authorityStart = original.IndexOf("://", StringComparison.Ordinal); var pathStart = authorityStart < 0 ? -1 : original.IndexOf('/', authorityStart + 3); var escaped = pathStart < 0 ? string.Empty : original[pathStart..].Split(['?', '#'], 2)[0].TrimStart('/'); if (escaped.Contains("%25", StringComparison.OrdinalIgnoreCase) || escaped.Contains("%2e", StringComparison.OrdinalIgnoreCase) || escaped.Contains("%5c", StringComparison.OrdinalIgnoreCase) || escaped.Contains("%00", StringComparison.OrdinalIgnoreCase) || escaped.Contains('\\') || escaped.Contains(':')) { return false; } string decoded; try { decoded = Uri.UnescapeDataString(escaped).Replace('\\', '/'); } catch (UriFormatException) { return false; } return !decoded.StartsWith("/", StringComparison.Ordinal) && decoded.Split('/', StringSplitOptions.RemoveEmptyEntries) .All(segment => segment is not "." and not ".." && !Path.IsPathFullyQualified(segment)); } }