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) + } +}