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
@@ -75,7 +75,7 @@ public static class AuthEndpoints
|
||||
|
||||
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
string source = http.ClientAddress();
|
||||
if (!limiter.TryAcquire(source, out int retryAfter))
|
||||
{
|
||||
return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
||||
@@ -130,7 +130,7 @@ public static class AuthEndpoints
|
||||
/// <summary>限流。<paramref name="scope"/> 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。</summary>
|
||||
private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null)
|
||||
{
|
||||
string source = scope + (http.Connection.RemoteIpAddress?.ToString() ?? "unknown");
|
||||
string source = scope + (http.ClientAddress());
|
||||
ok = limiter.TryAcquire(source, out int retryAfter, limit);
|
||||
return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
||||
}
|
||||
@@ -161,7 +161,7 @@ public static class AuthEndpoints
|
||||
|
||||
Result<LoginResult> result = await passkeys.LoginAsync(
|
||||
new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
string? remote = http.Connection.RemoteIpAddress?.ToString();
|
||||
string? remote = http.ClientAddress();
|
||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted);
|
||||
@@ -276,7 +276,7 @@ public static class AuthEndpoints
|
||||
principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
|
||||
if (!result.TryGet(out ElevationWindow? window, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.Connection.RemoteIpAddress?.ToString(), false, http.RequestAborted);
|
||||
await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.ClientAddress(), false, http.RequestAborted);
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ public static class ApiModule
|
||||
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<SseLimiter>();
|
||||
services.AddSingleton<ClientAddressResolver>();
|
||||
services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
JsonSerializerOptions json = options.SerializerOptions;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ public static class LiveEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
string client = http.ClientAddress();
|
||||
if (!limiter.TryAcquire(client))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
|
||||
|
||||
@@ -50,6 +50,15 @@ public sealed class BackendOptions
|
||||
/// </summary>
|
||||
public string? CorsOrigins { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可信的反向代理(逗号分隔的 IP 或 CIDR,如 <c>127.0.0.1,172.16.0.0/12</c>)。只有从这些地址连进来的请求,才采信
|
||||
/// <see cref="TrustedProxyHeader"/> 里的客户端地址(用于登录限流、审计和 SSE 限制);空 = 不信任任何转发头,用套接字地址。
|
||||
/// </summary>
|
||||
public string? TrustedProxies { get; set; }
|
||||
|
||||
/// <summary>携带客户端地址的头,缺省 <c>X-Forwarded-For</c>(Cloudflare 单独的 <c>CF-Connecting-IP</c> 也可以填这里)。</summary>
|
||||
public string? TrustedProxyHeader { get; set; }
|
||||
|
||||
public TimeSpan SessionLifetime { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
public int LoginAttemptsPerMinute { get; set; } = 10;
|
||||
|
||||
Reference in new issue
Block a user