Files
MailUI4Agents/server/internal/middleware/localonly.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

46 lines
1.5 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 (
"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()
}