Files
YMhut-box-C-/src/YMhut.Box.Core/Plugins/PluginNetworkPolicy.cs
T

161 lines
5.7 KiB
C#

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<string>? 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<bool> 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));
}
}