# 之前的缺口 `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 正常
64 lines
2.0 KiB
Go
64 lines
2.0 KiB
Go
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)
|
||
}
|
||
}
|