fix(setup): 首个管理员的创建只允许 Gateway 本机

# 之前的缺口

`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 正常
This commit is contained in:
2026-09-11 15:49:26 +08:00
parent 4050827e5c
commit 4186ad4784
4 changed files with 118 additions and 3 deletions

View File

@ -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.1LAN/反代请求一律拒绝。
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)

View File

@ -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) {

View File

@ -0,0 +1,45 @@
package middleware
import (
"context"
"net"
"net/http"
)
type peerAddressKey struct{}
// CapturePeerAddress 在 RealIP 中间件改写 RemoteAddr 之前保存真实 TCP 对端。
//
// 不能在 LocalOnly 里直接读 r.RemoteAddrchi 的 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()
}

View File

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