From 73ee4e74c7bcd493891c655f2f9315b6e7850dea Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 10:44:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=87=AA=E5=8A=A9=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E6=8C=89=20trusted=5Fproxies=20=E8=A7=A3=E6=9E=90=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/nixmsg/serve.go | 5 +- docs/DEVIATIONS.md | 9 +++ internal/app/identity/register_test.go | 78 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/cmd/nixmsg/serve.go b/cmd/nixmsg/serve.go index 876288a..2f94955 100644 --- a/cmd/nixmsg/serve.go +++ b/cmd/nixmsg/serve.go @@ -137,6 +137,7 @@ func runServe(ctx context.Context, cfg config.Config) error { sess.SetPresence(presApp) uplink.presence = presApp + trustedNets := httpx.ParseCIDRs(cfg.TrustedProxies) idApp := identity.New(identity.Config{ DB: db, Hash: hashPool, @@ -145,6 +146,9 @@ func runServe(ctx context.Context, cfg config.Config) error { MaxScheduleSeconds: int64(cfg.Limits.MaxScheduleSeconds), Logger: slog.Default(), ConnControl: brk, + ClientIP: func(r *http.Request) string { + return httpx.ClientIP(r, trustedNets) + }, }) uplink.identity = idApp @@ -161,7 +165,6 @@ func runServe(ctx context.Context, cfg config.Config) error { return fmt.Errorf("message recover: %w", recoverErr) } - trustedNets := httpx.ParseCIDRs(cfg.TrustedProxies) adminHandler := admin.New(admin.Deps{ DB: db, Hash: hashPool, diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 3ebcd47..a64f378 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1092,3 +1092,12 @@ - 原因:同一连接上 `group.create`/`group.add` 同步向本连接注入下行时,与 mochi InlineClient 互相等待,`resp` 回不去(`TestUplinkDMOfflineGroupRecall` 在清掉测试客户端 dial deadline 后稳定复现)。 - 备选方案:broker 层对 Inline 发布做无锁队列。 - 影响:`group_event` 可能略晚于 `resp` 到达;业务结果仍以 `resp` 为准。 + +### fix-issue-2 + +1. **自助注册接入 trusted_proxies 客户端 IP** + - 原条款:PRD F23 / D18 注册安全码按来源 IP 锁定;DEVELOPMENT 4.5 来自受信代理时用 `X-Forwarded-For`;I1.4 曾写「经代理部署时接线方必须注入真实 IP」。 + - 实际做法:`cmd/nixmsg/serve.go` 在 `identity.New` 注入与管理接口相同的 `httpx.ClientIP(r, trustedNets)`;不改锁定阈值与注册开关/安全码语义,不在 identity 内复制解析。 + - 原因:L-WIRE 已挂注册 Handler,管理与 WS 已接 `trusted_proxies`,唯独注册漏接,反向代理后会把安全码锁定计到代理 IP。 + - 备选方案:在 listener 层统一改写 `RemoteAddr` 后再交给注册 Handler。 + - 影响:经受信代理开放注册时,输错安全码按真实客户端 IP 锁定。 diff --git a/internal/app/identity/register_test.go b/internal/app/identity/register_test.go index cb49fd6..4b7699a 100644 --- a/internal/app/identity/register_test.go +++ b/internal/app/identity/register_test.go @@ -15,6 +15,7 @@ import ( "time" "git.asio.asia/nixevol/NixMsg/internal/auth" + "git.asio.asia/nixevol/NixMsg/internal/httpx" "git.asio.asia/nixevol/NixMsg/internal/protocol" "git.asio.asia/nixevol/NixMsg/internal/store" ) @@ -309,6 +310,83 @@ func TestRegisterF23_WrongCodeLock(t *testing.T) { } } +// TestRegisterTrustedProxyClientIPLock 验证与管理接口相同的 httpx.ClientIP: +// 受信代理的 X-Forwarded-For 按真实客户端 IP 计锁;非信任来源不采信转发头。 +func TestRegisterTrustedProxyClientIPLock(t *testing.T) { + db, err := store.Open(t.TempDir(), "FULL") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + locks := newRegisterIPLocker(time.Now) + trusted := httpx.ParseCIDRs([]string{"127.0.0.1/32"}) + handler := NewRegisterHandler(RegisterConfig{ + DB: db, + Hash: auth.NewStubHashPool(), + Locks: locks, + ClientIP: func(r *http.Request) string { + return httpx.ClientIP(r, trusted) + }, + }) + env := &testEnv{db: db, hash: auth.NewStubHashPool(), locks: locks, handler: handler} + env.setRegistration(t, true, "proxy-lock-1") + + post := func(remote, xff, body string) (int, registerResp) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/client/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = remote + if xff != "" { + req.Header.Set("X-Forwarded-For", xff) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + var resp registerResp + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, rr.Body.String()) + } + return rr.Code, resp + } + + wrong := `{"registration_code":"wrong-code","id":"ep_px","login_password":"password1"}` + good := `{"registration_code":"proxy-lock-1","id":"ep_px","login_password":"password1"}` + + for i := 0; i < 10; i++ { + code, resp := post("127.0.0.1:9000", "198.51.100.7", wrong) + if code != http.StatusForbidden || resp.Error == nil || resp.Error.Code != protocol.CodeRegistrationCodeInvalid { + t.Fatalf("trusted fail #%d: status=%d resp=%+v", i+1, code, resp) + } + } + code, resp := post("127.0.0.1:9000", "198.51.100.7", good) + if code != http.StatusTooManyRequests || resp.Error == nil || resp.Error.Code != protocol.CodeRateLimited { + t.Fatalf("real client should be locked: status=%d resp=%+v", code, resp) + } + code, resp = post("127.0.0.1:9000", "198.51.100.8", good) + if code != http.StatusOK || !resp.OK || resp.Data.ID != "ep_px" { + t.Fatalf("other XFF client must not share lock: status=%d resp=%+v", code, resp) + } + + // 非信任对端:忽略 XFF,按 RemoteAddr 计锁。 + locks.Clear(auth.LockKey{Kind: auth.LockRegisterIP, IP: "198.51.100.7"}) + locks.Clear(auth.LockKey{Kind: auth.LockRegisterIP, IP: "203.0.113.50"}) + for i := 0; i < 10; i++ { + code, resp := post("203.0.113.50:4433", "198.51.100.7", wrong) + if code != http.StatusForbidden || resp.Error == nil || resp.Error.Code != protocol.CodeRegistrationCodeInvalid { + t.Fatalf("untrusted fail #%d: status=%d resp=%+v", i+1, code, resp) + } + } + code, resp = post("203.0.113.50:4433", "198.51.100.7", `{"registration_code":"proxy-lock-1","id":"ep_px2","login_password":"password1"}`) + if code != http.StatusTooManyRequests || resp.Error == nil || resp.Error.Code != protocol.CodeRateLimited { + t.Fatalf("untrusted RemoteAddr should be locked: status=%d resp=%+v", code, resp) + } + // 若误采信 XFF,198.51.100.7 会已锁;直连该 IP 应仍可注册。 + code, resp = post("198.51.100.7:5555", "", `{"registration_code":"proxy-lock-1","id":"ep_px3","login_password":"password1"}`) + if code != http.StatusOK || !resp.OK || resp.Data.ID != "ep_px3" { + t.Fatalf("spoofed XFF must not lock real client: status=%d resp=%+v", code, resp) + } +} + func TestRegisterF23_IDTakenKeepsOriginal(t *testing.T) { env := openTestEnv(t) env.setRegistration(t, true, "taken-code")