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;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using MoShiro.Backend.Api;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实来源地址:只有从可信代理连进来的请求才采信转发头,而且取最右边第一个不可信的地址。</summary>
|
||||
public sealed class ClientAddressTests
|
||||
{
|
||||
private static string Resolve(string? trusted, string remote, string? forwarded, string? header = null)
|
||||
{
|
||||
var resolver = new ClientAddressResolver(new BackendOptions { TrustedProxies = trusted, TrustedProxyHeader = header });
|
||||
var http = new DefaultHttpContext();
|
||||
http.Connection.RemoteIpAddress = IPAddress.Parse(remote);
|
||||
if (forwarded is not null)
|
||||
{
|
||||
http.Request.Headers[header ?? "X-Forwarded-For"] = forwarded;
|
||||
}
|
||||
|
||||
return resolver.Resolve(http);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutTrustedProxies_TheSocketAddressIsUsed_AndTheHeaderIsIgnored() =>
|
||||
Assert.Equal("203.0.113.9", Resolve(null, "203.0.113.9", "1.2.3.4"));
|
||||
|
||||
[Fact]
|
||||
public void FromATrustedProxy_TheForwardedClientIsUsed() =>
|
||||
Assert.Equal("198.51.100.7", Resolve("10.0.0.0/8", "10.1.2.3", "198.51.100.7"));
|
||||
|
||||
[Fact]
|
||||
public void FromAnUntrustedPeer_TheHeaderIsIgnored() =>
|
||||
Assert.Equal("203.0.113.9", Resolve("10.0.0.0/8", "203.0.113.9", "1.2.3.4"));
|
||||
|
||||
[Fact]
|
||||
public void TheChainIsWalkedFromTheRight_SoAClientCannotForgeItsAddress() =>
|
||||
Assert.Equal("198.51.100.7", Resolve("10.0.0.0/8,127.0.0.1", "10.1.2.3", "6.6.6.6, 198.51.100.7, 10.9.9.9"));
|
||||
|
||||
[Fact]
|
||||
public void AGarbageHeader_FallsBackToTheSocketAddress() =>
|
||||
Assert.Equal("10.1.2.3", Resolve("10.0.0.0/8", "10.1.2.3", "not-an-ip"));
|
||||
|
||||
[Fact]
|
||||
public void ASingleAddressAndACustomHeaderWork() =>
|
||||
Assert.Equal("198.51.100.7", Resolve("127.0.0.1", "127.0.0.1", "198.51.100.7", "CF-Connecting-IP"));
|
||||
|
||||
[Fact]
|
||||
public void AMalformedTrustedProxyEntry_FailsAtStartup() =>
|
||||
Assert.Throws<InvalidOperationException>(() => new ClientAddressResolver(new BackendOptions { TrustedProxies = "banana" }));
|
||||
}
|
||||
+52
-6
@@ -191,6 +191,36 @@
|
||||
- 节点能强制:谁是成员(链)、时间(Grant 窗口、Ticket 过期、单券 TTL 上限)、角色、吊销。
|
||||
- 节点强制不了:数量上限(它不知道别的节点)。由签名人执行(浏览器或 Backend 的 key 路径)。
|
||||
|
||||
### 7.4 现状的缺口:节点没有任何东西可以用来识别 Backend
|
||||
|
||||
今天的链路(已核对代码):
|
||||
|
||||
```text
|
||||
安装 curl https://<面板>/install?code=jt_… | bash 信任 HTTPS 证书,第一次联系即信任(TOFU)
|
||||
注册 Agent 生成 node.key 和 tls.pfx,用 jt_ 码把公钥交给 Backend
|
||||
连接 Backend 发挑战,Agent 用 node.key 签名 只有「Agent 向 Backend 证明自己」
|
||||
反方向 Agent 验证 Backend?没有。只靠 wss 的 WebPKI 证书;节点本地只存 node_id、面板地址、自己的证书指纹
|
||||
```
|
||||
|
||||
后果:Backend 的身份就是「能让 Agent 连上的、证书有效的服务器」。DNS 或前置代理被攻破、被签发了该域名的证书、或 Backend 机器被拿下,攻击者都等于 Backend,可以改 Agent 的 `peers` 名单(把自己的 Probe 公钥放进去)、下发任务、读全部数据。「换一个 Backend 公钥配自己的私钥」在这里甚至不需要,因为节点从来没有存过 Backend 的公钥。
|
||||
|
||||
要求:信任根必须是操作员的 passkey 公钥,在节点**第一次安装时固定**,之后 Backend 无法替换:
|
||||
|
||||
- 节点收到的所有「改信任」的内容(成员名单、授权、吊销表)都必须带操作员的签名,节点自己用固定下来的创世公钥验证。Backend 只能转发,签不出新的。
|
||||
- Backend 被冒充也只能转发操作员签过的东西:已有的授权可以重放(受有效期、吊销表约束),新授权签不出来。
|
||||
- 弱点:创世公钥指纹放在安装命令里,命令本身要从可信渠道拿到(管理端复制)。改了命令的人可以换指纹;缓解见第 15 节(静态签名脚本、按 digest 拉镜像),并让管理端展示指纹供核对。
|
||||
|
||||
### 7.5 第一个落地切片(重启、改配置的票和 webssh 先不做,只留接口)
|
||||
|
||||
| 步 | 内容 | 验证 |
|
||||
|---|---|---|
|
||||
| a | 创世:第一次用 passkey 登记操作员公钥(OpSet v1,自证);安装命令带上指纹 | 命令里的指纹和管理端展示一致 |
|
||||
| b | Grant:生成加入码时由 passkey 签(`challenge = SHA256(Grant)`);Agent 和 Probe 注册时验签,通过才保存操作员公钥 | 没有签名或签名不对的码,节点拒绝注册 |
|
||||
| c | 成员名单带签名:Agent 的 `peers`、Probe 的 `probe_agents` 每项带自己的链,节点在状态同步时验,不过就丢弃并计数 | 冒充的 Backend 往名单里加 Probe 公钥,节点不接纳 |
|
||||
| d | 数据加密(PRF)、`enroll` key、Ticket 作为后续,接口先留在 Grant 里(`controllers`、数据公钥字段) | |
|
||||
|
||||
不在这个切片里:重启和改配置不需要签名的票;webssh 等 extension 见第 8 节。
|
||||
|
||||
## 8. 节点属主与 extension 授权(预留)
|
||||
|
||||
后面会做 extension(web ssh、文件、抓包,见 [security.md](security.md) 第 11 节)。这类能力**连管理员都不信任**:管理员能删,不能读,也不能用。所以入网链现在就要给每个节点留出「属主」这个概念,即使 extension 还没做,字段也先固定下来,之后不用重装节点。
|
||||
@@ -283,7 +313,7 @@
|
||||
|
||||
1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。
|
||||
2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**已实现**:passkey 登录(第 3.1 节)、提权窗口(接入 15 分钟、吊销 5 分钟、凭证类 60 秒用一次;窗口属于会话,在内存里,API key 不受影响;创建带信任类能力的 key 和增删 passkey 要凭证窗口,第一个 passkey 例外)、审计日志(`GET /api/admin/audit`,写操作和登录自动记录,保留 365 天)、归属(加入代码的创建者、节点的添加者)。
|
||||
3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。
|
||||
3. **操作员集合与创世公钥固定**(落地切片见第 7.5 节):`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。
|
||||
4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
|
||||
5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。
|
||||
|
||||
@@ -306,13 +336,29 @@ Backend 被篡改时,你需要一个**不经过 Backend 账户系统**的信
|
||||
|
||||
## 15. 安装脚本的供应链
|
||||
|
||||
一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法:
|
||||
一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法,**优先级最高的是第一跳的哈希核对**:
|
||||
|
||||
- **脚本是静态产物**:不在服务端做字符串替换(替换会破坏签名)。CI 用发布密钥给 `install.sh`、`install.ps1` 签名,随 Backend 一起发布;参数(代码、面板地址)通过命令行参数传入,代码也因此不再出现在 URL 和反向代理的访问日志里:`curl -fsSL https://panel/install | sh -s -- '<code>'`。
|
||||
- **镜像按 digest 拉取**:脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用脚本里内置的发布公钥验签,再按清单里的 digest 拉镜像。Backend 改不了 digest,最多让节点装到一个旧的合法版本。
|
||||
**① 脚本是静态产物,哈希固定(优先做)**
|
||||
- 不在服务端做字符串替换。CI 构建时算出 `install.sh`、`install.ps1` 的 SHA-256,写进发布清单(发布密钥签名),同时公布在 Gitea 发布页——**这条渠道独立于 Backend**,这是哈希核对有意义的前提:哈希若和脚本从同一处来,攻击者两个一起换就行。
|
||||
- 参数(代码、面板地址)通过命令行传入,代码也因此不再出现在 URL 和反向代理日志里。
|
||||
- 管理端给的命令把「下载、核对、执行」合成一行,哈希核对不通过就不执行:
|
||||
|
||||
```text
|
||||
curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo "<sha256> /tmp/moshiro-install.sh" | sha256sum -c - && sh /tmp/moshiro-install.sh '<code>'
|
||||
```
|
||||
|
||||
macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 `throw`。命令里的 `<sha256>` 由 Backend 填入,所以管理端同时标出「与 Gitea 发布页的 SHA-256 一致」并提供链接;严格的人对照一次即可(脚本是静态的,哈希在一个版本内不变,只需要核对一次)。
|
||||
- 进一步:操作员确认这个版本的发布时(`update.confirm`,要 passkey),把脚本哈希一起签进去,Backend 被攻破也改不了命令里的哈希;节点和安装命令里的创世公钥指纹(第 7.5 节)来自同一次签名。
|
||||
|
||||
**② 镜像按 digest 拉取,二进制按 SHA-256 校验**
|
||||
- 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`);原生二进制则核对清单里的 SHA-256 再安装。Backend 改不了 digest,最多让节点装到一个旧的合法版本。
|
||||
- 发布公钥因此既在脚本里,也编进 Agent 和 Probe 二进制(一个常量,来自 `packaging/update/release-key.pub.pem`),节点之后的更新由节点自己验,不依赖宿主机更新器和 Backend 转发的内容。发布密钥轮换时,新公钥必须由旧密钥签名的清单带下来,否则节点不认。
|
||||
- 更新建议要求**双签**:CI 的发布签名,加操作员 passkey 对清单摘要的批准签名。只攻破 CI 或只攻破 Backend 都不够。
|
||||
|
||||
**③ 其余**
|
||||
- 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
|
||||
- 剩下的第一跳信任(脚本本身):想要严格的人可以先下载脚本,对照 Gitea 发布页公布的 SHA-256 再执行;管理端同时给出「先下载再核对」的写法。Windows 版同理,另外可以加 Authenticode 签名。
|
||||
- 放在阶段五(Backend 应用层身份固定)一起做。
|
||||
- Windows 版同理,另外可以加 Authenticode 签名。
|
||||
- 今天的现状:一行安装不装更新器,也不带发布公钥,节点自己不验任何签名;这一节是要补的缺口。
|
||||
|
||||
## 16. 效率(运行时开销与日常摩擦)
|
||||
|
||||
|
||||
@@ -232,6 +232,8 @@ sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe
|
||||
sudo sh deploy-node.sh backend --channel dev --admin-password '<密码>' --public-url https://<面板域名>
|
||||
# 其他环境变量用 --env 原样写入(只在首次创建 backend.env 时生效):本地开发前端要连这个面板时加
|
||||
# --env MoShiro__CorsOrigins=http://localhost:5173,http://localhost:5174 --env MoShiro__SecureCookies=false
|
||||
# 面板前面有反向代理(Caddy、frp、Cloudflare)时,登录限流和审计看到的是代理的地址:把代理的地址写进可信代理,才会采信转发头里的客户端地址
|
||||
# --env MoShiro__TrustedProxies=127.0.0.1,172.16.0.0/12 (头默认 X-Forwarded-For,可用 MoShiro__TrustedProxyHeader 改成 CF-Connecting-IP)
|
||||
# 之后更新:不带参数(旧版 Probe 的 probe.json 没有 fleet 段,加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old)
|
||||
sudo sh deploy-node.sh agent
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user