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:
justme committed 2026-10-07 22:51:44 +08:00
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();
});
}