From a19e6548d318ae59d50215643ed806ec13d7947c Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 22:51:44 +0800 Subject: [PATCH] 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. --- .../Admin/AuthEndpoints.cs | 8 +- backend/src/MoShiro.Backend.Api/ApiModule.cs | 1 + .../Common/ClientAddress.cs | 80 +++++++++++++++++++ .../src/MoShiro.Backend.Api/Common/Guards.cs | 2 +- .../Public/LiveEndpoints.cs | 2 +- .../BackendOptions.cs | 9 +++ .../ClientAddressTests.cs | 51 ++++++++++++ docs/access.md | 58 ++++++++++++-- docs/deployment.md | 2 + 9 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 77920d3..622d760 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -75,7 +75,7 @@ public static class AuthEndpoints private static async Task 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 /// 限流。 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。 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 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); } diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 95d72f0..8a9da2d 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -18,6 +18,7 @@ public static class ApiModule public static IServiceCollection AddMoShiroApi(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.Configure(options => { JsonSerializerOptions json = options.SerializerOptions; diff --git a/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs b/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs new file mode 100644 index 0000000..bf61a94 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs @@ -0,0 +1,80 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Api; + +/// +/// 请求的真实来源地址。后端前面通常有反向代理(Cloudflare、frp、Caddy),套接字上看到的是最近一跳; +/// 只有配置了 ,并且连进来的正是其中之一,才相信转发头, +/// 且从右往左跳过所有可信代理,取第一个不可信的地址(客户端自己伪造的在最左边,不会被采信)。 +/// 没配置就一律用套接字地址:宁可限流和审计里是代理的地址,也不让客户端自己报地址。 +/// +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().Resolve(http); +} diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 14610d7..221c1cf 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -109,7 +109,7 @@ public static class CapabilityGuard { http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail); return http.RequestServices.GetRequiredService() - .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(); }); } diff --git a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs index 49ccad4..476656a 100644 --- a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs @@ -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"); diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 9dd255f..1b1c1d6 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -50,6 +50,15 @@ public sealed class BackendOptions /// public string? CorsOrigins { get; set; } + /// + /// 可信的反向代理(逗号分隔的 IP 或 CIDR,如 127.0.0.1,172.16.0.0/12)。只有从这些地址连进来的请求,才采信 + /// 里的客户端地址(用于登录限流、审计和 SSE 限制);空 = 不信任任何转发头,用套接字地址。 + /// + public string? TrustedProxies { get; set; } + + /// 携带客户端地址的头,缺省 X-Forwarded-For(Cloudflare 单独的 CF-Connecting-IP 也可以填这里)。 + public string? TrustedProxyHeader { get; set; } + public TimeSpan SessionLifetime { get; set; } = TimeSpan.FromHours(12); public int LoginAttemptsPerMinute { get; set; } = 10; diff --git a/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs b/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs new file mode 100644 index 0000000..6e785d8 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using MoShiro.Backend.Api; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// 真实来源地址:只有从可信代理连进来的请求才采信转发头,而且取最右边第一个不可信的地址。 +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(() => new ClientAddressResolver(new BackendOptions { TrustedProxies = "banana" })); +} diff --git a/docs/access.md b/docs/access.md index 33e660a..fede523 100644 --- a/docs/access.md +++ b/docs/access.md @@ -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 -- ''`。 -- **镜像按 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 " /tmp/moshiro-install.sh" | sha256sum -c - && sh /tmp/moshiro-install.sh '' +``` + + macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 `throw`。命令里的 `` 由 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. 效率(运行时开销与日常摩擦) diff --git a/docs/deployment.md b/docs/deployment.md index 0347125..6726c11 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 ```