From 4186ad478464657abf57725cc3ed448f2f17cb34 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 11 Sep 2026 15:49:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(setup):=20=E9=A6=96=E4=B8=AA=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E7=9A=84=E5=88=9B=E5=BB=BA=E5=8F=AA=E5=85=81?= =?UTF-8?q?=E8=AE=B8=20Gateway=20=E6=9C=AC=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 之前的缺口 `POST /api/v1/setup/admin` 是公开路由,唯一的门是「系统还没有任何用户」。 Gateway 监听 `*:8180`,于是局域网里任何人可以绕开 nginx 直接调它。 本机已初始化时它只回 409,所以这条是纵深防御;但在**尚未初始化**的部署上, 它是「谁先提交谁成为管理员」——一个可被抢注的管理员入口。 # 为什么不是收紧监听地址 `.106` 上的反代(公网 `mail.jianfgit.xyz`)与本机鸿蒙客户端都直连 `192.168.2.60:8180`,把监听收到 127.0.0.1 会把这两条入口一起切断。 缺口在端点本身,不在监听面,所以只收紧端点。 # 改动 - 新增 `middleware.LocalOnly`:只有真实 TCP 对端为回环地址才放行。 - 新增 `middleware.CapturePeerAddress`,**注册在 `chimw.RealIP` 之前**。 RealIP 会信任 `X-Forwarded-For` 并改写 `RemoteAddr`,直接读它等于让外部 调用者用一个请求头冒充本机;所以先存原始连接地址,安全判断只认那份。 - `SetupAdmin` 的注释同步:本机限制在路由层,`NeedsSetup` 保留为第二道防线。 - 测试(`localonly_test.go`)按生产中间件顺序组装链,覆盖: IPv4/IPv6 回环放行、局网拒绝、**伪造 X-Forwarded-For 仍拒绝**、 非法地址拒绝,以及未装 CapturePeerAddress 时 fail closed。 # 验证 - 回环 `/setup/admin` → 409(进入处理器,系统已初始化) - LAN `/setup/admin` → 403 - LAN + `X-Forwarded-For: 127.0.0.1` → 403 - LAN `/setup/status` → 200(登录页判断是否显示向导仍正常) - 登录 + 收件箱 → 200;Go 全量测试与 vet 通过;已部署,四桥/SSE 正常 --- server/cmd/server/main.go | 10 +++- server/internal/handler/auth.go | 3 +- server/internal/middleware/localonly.go | 45 ++++++++++++++ server/internal/middleware/localonly_test.go | 63 ++++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 server/internal/middleware/localonly.go create mode 100644 server/internal/middleware/localonly_test.go diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 96e4696..ed62e10 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -71,6 +71,9 @@ func main() { r.Use(chimw.Logger) r.Use(chimw.Recoverer) r.Use(chimw.RequestID) + // 必须在 RealIP 之前保存真实 TCP 对端;否则外部请求可伪造 + // X-Forwarded-For: 127.0.0.1 绕过 /setup/admin 的本机限制。 + r.Use(middleware.CapturePeerAddress) r.Use(chimw.RealIP) r.Use(cors.Handler(cors.Options{ AllowedOrigins: cfg.CORSOrigins, @@ -89,9 +92,12 @@ func main() { }) r.Route("/api/v1", func(r chi.Router) { - // ---- 首次初始化(公开;仅系统无用户时可用) ---- + // ---- 首次初始化 ---- + // status 公开,供登录页判断是否显示初始化向导;真正创建管理员只允许 + // Gateway 本机调用。标准部署会在监听前由 bootstrapAdmin 创建管理员, + // 手工初始化则需在服务器上访问 127.0.0.1,LAN/反代请求一律拒绝。 r.Get("/setup/status", handler.SetupStatus) - r.Post("/setup/admin", handler.SetupAdmin) + r.With(middleware.LocalOnly).Post("/setup/admin", handler.SetupAdmin) // ---- 认证(公开) ---- r.Post("/auth/login", handler.Login) diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 099c668..449ca05 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -67,7 +67,8 @@ type setupRequest struct { DisplayName string `json:"display_name"` } -// POST /api/v1/setup/admin —— 公开,但仅在系统无任何用户时可用 +// POST /api/v1/setup/admin —— 仅允许 Gateway 本机调用,且仅在系统无任何用户时可用。 +// 路由层的 middleware.LocalOnly 负责本机限制;这里保留 NeedsSetup 作为第二道防线。 func SetupAdmin(w http.ResponseWriter, r *http.Request) { var req setupRequest if !DecodeBody(w, r, &req) { diff --git a/server/internal/middleware/localonly.go b/server/internal/middleware/localonly.go new file mode 100644 index 0000000..042064a --- /dev/null +++ b/server/internal/middleware/localonly.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "context" + "net" + "net/http" +) + +type peerAddressKey struct{} + +// CapturePeerAddress 在 RealIP 中间件改写 RemoteAddr 之前保存真实 TCP 对端。 +// +// 不能在 LocalOnly 里直接读 r.RemoteAddr:chi 的 RealIP 会信任 +// X-Forwarded-For,外部调用者可伪造该头冒充 127.0.0.1。这个中间件必须注册在 +// RealIP 之前,后续安全判断只读这里保存的原始连接地址。 +func CapturePeerAddress(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), peerAddressKey{}, r.RemoteAddr) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// LocalOnly 只允许真实 TCP 对端为回环地址的请求。 +// 未经过 CapturePeerAddress、地址格式错误或非 IP 地址时一律拒绝(fail closed)。 +func LocalOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + peer, ok := r.Context().Value(peerAddressKey{}).(string) + if !ok || !isLoopbackAddress(peer) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"该操作只允许从 Gateway 本机执行"}`)) + return + } + next.ServeHTTP(w, r) + }) +} + +func isLoopbackAddress(address string) bool { + host, _, err := net.SplitHostPort(address) + if err != nil { + return false + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/server/internal/middleware/localonly_test.go b/server/internal/middleware/localonly_test.go new file mode 100644 index 0000000..28f86cc --- /dev/null +++ b/server/internal/middleware/localonly_test.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + chimw "github.com/go-chi/chi/v5/middleware" +) + +func TestLocalOnlyUsesOriginalTCPPeer(t *testing.T) { + allowed := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + // 与生产顺序一致:先保存 TCP 对端,再由 RealIP 处理日志所用地址,最后做本机限制。 + chain := CapturePeerAddress(chimw.RealIP(LocalOnly(allowed))) + + cases := []struct { + name string + remoteAddr string + forwarded string + want int + }{ + {name: "IPv4 回环允许", remoteAddr: "127.0.0.1:41000", want: http.StatusNoContent}, + {name: "IPv6 回环允许", remoteAddr: "[::1]:41000", want: http.StatusNoContent}, + {name: "局网地址拒绝", remoteAddr: "192.168.2.106:41000", want: http.StatusForbidden}, + { + name: "伪造 X-Forwarded-For 仍拒绝", + remoteAddr: "192.168.2.106:41000", + forwarded: "127.0.0.1", + want: http.StatusForbidden, + }, + {name: "非法地址拒绝", remoteAddr: "localhost", want: http.StatusForbidden}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/v1/setup/admin", nil) + r.RemoteAddr = tc.remoteAddr + if tc.forwarded != "" { + r.Header.Set("X-Forwarded-For", tc.forwarded) + } + w := httptest.NewRecorder() + chain.ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("状态码 = %d,期望 %d;响应:%s", w.Code, tc.want, w.Body.String()) + } + }) + } +} + +func TestLocalOnlyFailsClosedWithoutCapturedPeer(t *testing.T) { + h := LocalOnly(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + r := httptest.NewRequest(http.MethodPost, "/api/v1/setup/admin", nil) + r.RemoteAddr = "127.0.0.1:41000" + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("未安装 CapturePeerAddress 时必须拒绝,实际状态码 %d", w.Code) + } +}