feat: trust forwarded client addresses only from configured proxies (rate limit, audit, SSE) [CI]
Docs: access.md covers the missing node-side Backend identity and the hash-first install flow.
This commit is contained in:
1 parent
ff45ad086e
commit
a19e6548d3
9 files changed
+201
-12
No files matched your search
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 请求的真实来源地址。后端前面通常有反向代理(Cloudflare、frp、Caddy),套接字上看到的是最近一跳;
|
||||
/// 只有配置了 <see cref="BackendOptions.TrustedProxies"/>,并且连进来的正是其中之一,才相信转发头,
|
||||
/// 且从右往左跳过所有可信代理,取第一个不可信的地址(客户端自己伪造的在最左边,不会被采信)。
|
||||
/// 没配置就一律用套接字地址:宁可限流和审计里是代理的地址,也不让客户端自己报地址。
|
||||
/// </summary>
|
||||
public sealed class ClientAddressResolver
|
||||
{
|
||||
private readonly IPNetwork[] _trusted;
|
||||
private readonly string _header;
|
||||
|
||||
public ClientAddressResolver(BackendOptions options)
|
||||
{
|
||||
_header = string.IsNullOrWhiteSpace(options.TrustedProxyHeader) ? "X-Forwarded-For" : options.TrustedProxyHeader.Trim();
|
||||
_trusted = [.. (options.TrustedProxies ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(ParseNetwork)];
|
||||
}
|
||||
|
||||
private static IPNetwork ParseNetwork(string text)
|
||||
{
|
||||
if (IPNetwork.TryParse(text, out IPNetwork network))
|
||||
{
|
||||
return network;
|
||||
}
|
||||
|
||||
if (IPAddress.TryParse(text, out IPAddress? single))
|
||||
{
|
||||
return new IPNetwork(single, single.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"TrustedProxies: '{text}' is not an IP address or CIDR range");
|
||||
}
|
||||
|
||||
private bool IsTrusted(IPAddress address)
|
||||
{
|
||||
IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
|
||||
return _trusted.Any(n => n.Contains(normalized));
|
||||
}
|
||||
|
||||
public string Resolve(HttpContext http)
|
||||
{
|
||||
IPAddress? remote = http.Connection.RemoteIpAddress;
|
||||
if (remote is null)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (_trusted.Length == 0 || !IsTrusted(remote))
|
||||
{
|
||||
return remote.ToString();
|
||||
}
|
||||
|
||||
string[] chain = [.. http.Request.Headers[_header].SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))];
|
||||
for (int i = chain.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (!IPAddress.TryParse(chain[i], out IPAddress? hop))
|
||||
{
|
||||
break; // 不是地址就不信这条头
|
||||
}
|
||||
|
||||
if (!IsTrusted(hop))
|
||||
{
|
||||
return hop.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return remote.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public static class ClientAddressExtensions
|
||||
{
|
||||
public static string ClientAddress(this HttpContext http) => http.RequestServices.GetRequiredService<ClientAddressResolver>().Resolve(http);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ public static class CapabilityGuard
|
||||
{
|
||||
http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail);
|
||||
return http.RequestServices.GetRequiredService<AuditService>()
|
||||
.RecordAsync(principal, action, target, detail as string, http.Connection.RemoteIpAddress?.ToString(), http.Response.StatusCode < 400).AsTask();
|
||||
.RecordAsync(principal, action, target, detail as string, http.ClientAddress(), http.Response.StatusCode < 400).AsTask();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user