Files
MailUI4Agents/server/internal/middleware/localonly_test.go
JianFeeeee 4186ad4784 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 正常
2026-09-11 15:49:26 +08:00

64 lines
2.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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