161 lines
5.7 KiB
C#
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));
|
|
}
|
|
}
|