From a4280e7abce4c04510419c3a8b68c4fab7b94b4c Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 18 Sep 2026 00:52:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E6=94=B9=E5=9F=BA=E7=BA=BF=EF=BC=9A?= =?UTF-8?q?=E5=B0=81=E5=AD=98=E5=8F=AF=E4=BF=A1=E4=BB=A3=E7=90=86=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E8=A7=A3=E6=9E=90=E4=B8=8E=E8=BF=9C=E7=A8=8B=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E5=80=99=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/login.go | 2 +- backend/controllers/security.go | 22 +-- .../controllers/security_remote_ip_test.go | 39 ++++++ backend/utils/clientip/clientip.go | 78 +++++++++++ backend/utils/clientip/clientip_test.go | 68 ++++++++++ .../utils/clientip/nginx_integration_test.go | 126 ++++++++++++++++++ docs/可信代理地址整改记录.md | 35 +++++ docs/生产整改验收清单.md | 6 + 8 files changed, 355 insertions(+), 21 deletions(-) create mode 100644 backend/utils/clientip/clientip.go create mode 100644 backend/utils/clientip/clientip_test.go create mode 100644 backend/utils/clientip/nginx_integration_test.go create mode 100644 docs/可信代理地址整改记录.md diff --git a/backend/controllers/login.go b/backend/controllers/login.go index 2114bcb..c9b9193 100644 --- a/backend/controllers/login.go +++ b/backend/controllers/login.go @@ -83,7 +83,7 @@ func loginRemoteHost(req *http.Request) string { if req == nil { return "unknown" } - host := normalizeRemoteHost(req.RemoteAddr) + host := remoteIP(req) if host == "" { host = "unknown" } diff --git a/backend/controllers/security.go b/backend/controllers/security.go index e859b12..c864c21 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "encoding/json" "errors" - "net" "net/http" "net/url" "strconv" @@ -23,6 +22,7 @@ import ( "git.pcux.cn/xuans/Smail/services/recovery" "git.pcux.cn/xuans/Smail/services/verification" webauthnservice "git.pcux.cn/xuans/Smail/services/webauthn" + "git.pcux.cn/xuans/Smail/utils/clientip" "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/password" log "github.com/sirupsen/logrus" @@ -1076,25 +1076,7 @@ func DisableExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http } func remoteIP(req *http.Request) string { - if req == nil { - return "" - } - return normalizeRemoteHost(req.RemoteAddr) -} - -func normalizeRemoteHost(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - if host, _, err := net.SplitHostPort(value); err == nil { - value = host - } - value = strings.Trim(value, "[]") - if parsed := net.ParseIP(value); parsed != nil { - return parsed.String() - } - return value + return clientip.Resolve(req, config.CurrentHTTPConfig().TrustedProxyCIDRs) } func maskEmail(value string) string { diff --git a/backend/controllers/security_remote_ip_test.go b/backend/controllers/security_remote_ip_test.go index bd1041f..16a3e06 100644 --- a/backend/controllers/security_remote_ip_test.go +++ b/backend/controllers/security_remote_ip_test.go @@ -4,6 +4,8 @@ import ( "net/http" "net/http/httptest" "testing" + + "git.pcux.cn/xuans/Smail/config" ) func TestRemoteIPNormalizesIPv4AndIPv6Addresses(t *testing.T) { @@ -31,6 +33,43 @@ func TestRemoteIPNormalizesIPv4AndIPv6Addresses(t *testing.T) { } } +func TestRemoteIPTrustedProxySeparatesLoginLimits(t *testing.T) { + oldConfig, oldManager := config.Instance, config.CurrentManager() + config.SetRuntimeManager(nil) + config.Instance = &config.Config{ + TLSMode: config.HTTPSTLSModeDirect, + TrustedProxyCIDRs: []string{"127.0.0.1/32"}, + } + t.Cleanup(func() { + config.Instance = oldConfig + config.SetRuntimeManager(oldManager) + }) + first := httptest.NewRequest(http.MethodPost, "https://mail.example.com/api/login", nil) + first.RemoteAddr = "127.0.0.1:1234" + first.Header.Set("X-Forwarded-For", "192.0.2.10") + second := first.Clone(first.Context()) + second.Header.Set("X-Forwarded-For", "192.0.2.11") + limiter := newLoginLimiter(1) + limiter.recordFailure(limiter.ipKey(first)) + if !limiter.blocked(limiter.ipKey(first)) || limiter.blocked(limiter.ipKey(second)) { + t.Fatal("可信代理后的两个客户端共享了 IP 限流状态") + } + if limiter.key(first, "user") == limiter.key(second, "user") { + t.Fatal("账号与 IP 组合限流没有区分客户端") + } + if remoteIP(first) != "192.0.2.10" || loginRemoteHost(first) != remoteIP(first) { + t.Fatal("安全验证与登录限流的客户端地址不一致") + } + second.RemoteAddr = "198.51.100.10:4321" + second.Header.Set("X-Forwarded-For", "192.0.2.10") + if remoteIP(second) != "198.51.100.10" { + t.Fatal("非可信连接能够伪造安全验证来源") + } + if first.RemoteAddr != "127.0.0.1:1234" { + t.Fatal("代理解析修改了真实连接来源") + } +} + func TestRemoteIPPreservesNonIPHostAndHandlesMissingAddress(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/", nil) request.RemoteAddr = "proxy.example:443" diff --git a/backend/utils/clientip/clientip.go b/backend/utils/clientip/clientip.go new file mode 100644 index 0000000..f5db6d0 --- /dev/null +++ b/backend/utils/clientip/clientip.go @@ -0,0 +1,78 @@ +package clientip + +import ( + "net" + "net/http" + "net/netip" + "strings" +) + +// Resolve 仅信任显式配置的代理,并从右向左寻找转发链中的首个非可信地址。 +// 原始 RemoteAddr 保持不变,初始化和本机访问控制仍应检查真实连接来源。 +func Resolve(request *http.Request, trustedCIDRs []string) string { + if request == nil { + return "" + } + peer := normalizePeer(request.RemoteAddr) + address, err := netip.ParseAddr(peer) + if err != nil || len(trustedCIDRs) == 0 { + return peer + } + networks := make([]netip.Prefix, 0, len(trustedCIDRs)) + for _, value := range trustedCIDRs { + network, err := netip.ParsePrefix(value) + if err != nil { + return peer + } + networks = append(networks, network) + } + trusted := func(address netip.Addr) bool { + for _, network := range networks { + if network.Contains(address.Unmap()) { + return true + } + } + return false + } + if !trusted(address) { + return peer + } + values := request.Header.Values("X-Forwarded-For") + if len(values) != 1 || len(values[0]) > 4096 { + return peer + } + parts := strings.Split(values[0], ",") + if len(parts) > 32 { + return peer + } + chain := make([]netip.Addr, 0, len(parts)) + for _, value := range parts { + address, err := netip.ParseAddr(strings.TrimSpace(value)) + if err != nil || address.Zone() != "" { + return peer + } + address = address.Unmap() + if address.IsUnspecified() || address.IsMulticast() { + return peer + } + chain = append(chain, address) + } + for index := len(chain) - 1; index >= 0; index-- { + if !trusted(chain[index]) || index == 0 { + return chain[index].String() + } + } + return peer +} + +func normalizePeer(value string) string { + value = strings.TrimSpace(value) + if host, _, err := net.SplitHostPort(value); err == nil { + value = host + } + value = strings.Trim(value, "[]") + if address, err := netip.ParseAddr(value); err == nil { + return address.Unmap().String() + } + return value +} diff --git a/backend/utils/clientip/clientip_test.go b/backend/utils/clientip/clientip_test.go new file mode 100644 index 0000000..c91ce2d --- /dev/null +++ b/backend/utils/clientip/clientip_test.go @@ -0,0 +1,68 @@ +package clientip + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestResolve(t *testing.T) { + tests := []struct { + name string + peer string + trusted []string + headers []string + want string + }{ + {"direct ipv4", "192.0.2.1:1234", nil, nil, "192.0.2.1"}, + {"direct ipv6", "[2001:db8::1]:1234", nil, nil, "2001:db8::1"}, + {"mapped ipv4", "[::ffff:192.0.2.1]:1234", nil, nil, "192.0.2.1"}, + {"host compatibility", "proxy.example:443", nil, nil, "proxy.example"}, + {"no implicit loopback trust", "127.0.0.1:1234", nil, []string{"192.0.2.1"}, "127.0.0.1"}, + {"untrusted ignores spoof", "192.0.2.1:1234", []string{"127.0.0.1/32"}, []string{"198.51.100.1"}, "192.0.2.1"}, + {"single proxy", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"192.0.2.1"}, "192.0.2.1"}, + {"mapped proxy", "[::ffff:127.0.0.1]:1234", []string{"127.0.0.1/32"}, []string{"::ffff:192.0.2.1"}, "192.0.2.1"}, + {"ipv6 proxy", "[::1]:1234", []string{"::1/128"}, []string{"2001:0db8::1"}, "2001:db8::1"}, + {"untrusted intermediate", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"198.51.100.5, 192.0.2.1"}, "192.0.2.1"}, + {"trusted intermediate", "127.0.0.1:1234", []string{"127.0.0.1/32", "10.0.0.0/24"}, []string{"192.0.2.1, 10.0.0.2"}, "192.0.2.1"}, + {"all trusted", "127.0.0.1:1234", []string{"127.0.0.0/8"}, []string{"127.0.0.2"}, "127.0.0.2"}, + {"empty header", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{""}, "127.0.0.1"}, + {"missing header", "127.0.0.1:1234", []string{"127.0.0.1/32"}, nil, "127.0.0.1"}, + {"duplicate header", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"192.0.2.1", "192.0.2.2"}, "127.0.0.1"}, + {"port rejected", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"192.0.2.1:80"}, "127.0.0.1"}, + {"zone rejected", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"fe80::1%eth0"}, "127.0.0.1"}, + {"unspecified rejected", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"0.0.0.0"}, "127.0.0.1"}, + {"mapped unspecified rejected", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"::ffff:0.0.0.0"}, "127.0.0.1"}, + {"mapped multicast rejected", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"::ffff:224.0.0.1"}, "127.0.0.1"}, + {"multicast rejected", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"ff02::1"}, "127.0.0.1"}, + {"malformed chain", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"unknown, 192.0.2.1"}, "127.0.0.1"}, + {"empty chain element", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{"192.0.2.1,"}, "127.0.0.1"}, + {"invalid configuration", "127.0.0.1:1234", []string{"127.0.0.1/32", "invalid"}, []string{"192.0.2.1"}, "127.0.0.1"}, + {"oversized header", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{strings.Repeat(" ", 4096) + "192.0.2.1"}, "127.0.0.1"}, + {"too many hops", "127.0.0.1:1234", []string{"127.0.0.1/32"}, []string{strings.Repeat("192.0.2.1,", 32) + "192.0.2.1"}, "127.0.0.1"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "https://mail.example.com/", nil) + request.RemoteAddr = test.peer + for _, header := range test.headers { + request.Header.Add("X-Forwarded-For", header) + } + request.Header.Set("X-Real-IP", "203.0.113.9") + request.Header.Set("Forwarded", "for=203.0.113.9") + if got := Resolve(request, test.trusted); got != test.want { + t.Fatalf("解析结果为 %q,预期 %q", got, test.want) + } + if request.RemoteAddr != test.peer { + t.Fatal("解析过程修改了真实连接来源") + } + }) + } +} + +func TestResolveNilRequest(t *testing.T) { + if Resolve(nil, []string{"127.0.0.1/32"}) != "" { + t.Fatal("空请求应返回空地址") + } +} diff --git a/backend/utils/clientip/nginx_integration_test.go b/backend/utils/clientip/nginx_integration_test.go new file mode 100644 index 0000000..0069c10 --- /dev/null +++ b/backend/utils/clientip/nginx_integration_test.go @@ -0,0 +1,126 @@ +package clientip + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +// 此测试仅在远程隔离网络运行,不读取宿主机配置或连接生产应用。 +func TestResolveThroughNginx(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("真实 Nginx 验收仅在 Linux 隔离环境执行") + } + nginx, err := exec.LookPath("nginx") + if err != nil { + t.Skip("当前环境未安装 Nginx,不能声明真实代理验收通过") + } + directory, err := os.MkdirTemp("", "smail-clientip-nginx-") + if err != nil { + t.Fatal(err) + } + t.Logf("隔离 Nginx 目录:%s", directory) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "client": Resolve(request, []string{"127.0.0.1/32"}), + "peer": normalizePeer(request.RemoteAddr), + }) + })) + defer backend.Close() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + _ = listener.Close() + configuration := fmt.Sprintf(`daemon off; +master_process off; +pid %s/nginx.pid; +error_log stderr; +events { worker_connections 64; } +http { + access_log off; + client_body_temp_path %s/body; + proxy_temp_path %s/proxy; + fastcgi_temp_path %s/fastcgi; + uwsgi_temp_path %s/uwsgi; + scgi_temp_path %s/scgi; + server { + listen %s; + location / { + proxy_pass %s; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + } + } +} +`, directory, directory, directory, directory, directory, directory, address, backend.URL) + path := filepath.Join(directory, "nginx.conf") + if err := os.WriteFile(path, []byte(configuration), 0600); err != nil { + t.Fatal(err) + } + logFile, err := os.Create(filepath.Join(directory, "nginx.log")) + if err != nil { + t.Fatal(err) + } + defer logFile.Close() + command := exec.Command(nginx, "-p", directory+"/", "-c", path) + command.Stdout, command.Stderr = logFile, logFile + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _ = command.Wait() + }() + deadline := time.Now().Add(5 * time.Second) + for { + connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond) + if err == nil { + _ = connection.Close() + break + } + if time.Now().After(deadline) { + output, _ := os.ReadFile(logFile.Name()) + t.Fatalf("隔离 Nginx 未就绪:%s", output) + } + time.Sleep(25 * time.Millisecond) + } + for _, source := range []string{"127.0.0.2", "127.0.0.3"} { + t.Run(source, func(t *testing.T) { + dialer := &net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP(source)}, Timeout: time.Second} + transport := &http.Transport{DialContext: dialer.DialContext, DisableKeepAlives: true} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 3 * time.Second} + request, err := http.NewRequest(http.MethodGet, "http://"+address+"/", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Add("X-Forwarded-For", "198.51.100.5") + request.Header.Add("X-Forwarded-For", "203.0.113.8") + request.Header.Set("X-Real-IP", "198.51.100.6") + request.Header.Set("Forwarded", "for=198.51.100.7") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var result map[string]string + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || result["client"] != source || result["peer"] != "127.0.0.1" { + t.Fatalf("真实代理地址校验失败:状态=%d,结果=%v", response.StatusCode, result) + } + }) + } +} diff --git a/docs/可信代理地址整改记录.md b/docs/可信代理地址整改记录.md new file mode 100644 index 0000000..af753f4 --- /dev/null +++ b/docs/可信代理地址整改记录.md @@ -0,0 +1,35 @@ +# 可信代理地址整改记录 + +## 范围与状态 + +本轮针对 Nginx 反代后登录 IP 限流、验证码绑定和自助恢复来源均被识别为回环地址的问题。基线提交为 `05b2445`,修改前已推送远程仓库。所有测试执行在 `47.108.78.12`;本机只编辑、静态检查、交叉编译 Linux 测试程序和上传,不运行测试程序。 + +- [✓] 明确配置的可信代理才允许传递客户端地址,空配置不隐式信任回环来源。 +- [✓] 登录限流和安全验证统一使用客户端地址解析,不修改请求的原始连接地址。 +- [✓] 26 个解析场景、空请求和真实隔离 Nginx 双来源测试通过。 +- [~] 控制器和 HTTP 层完整逐用例回归正在执行,最终结果以下方证据为准。 +- [ ] 最新应用构建、隔离安装、可信代理配置迁移、实际登录和验证码流程、生产部署及回退验收。 + +本轮未清理生产数据库、未改生产程序、未调整邮件端口、未开启公共管理接口。当前生产应用仍为此前部署版本,不能把候选测试通过描述为线上 IP 限流问题已解决。 + +## 实现边界 + +`backend/utils/clientip` 接收请求和 `http.trustedProxyCidrs`,先检查真实 TCP 来源是否位于显式配置的可信网段。只有可信来源的单个 `X-Forwarded-For` 头可参与解析;不将 `X-Real-IP` 或 `Forwarded` 当作回退来源。 + +多级代理链从右向左检查,停在首个非可信地址,不使用任意客户端可添加的最左侧地址。重复头、非法或空链元素、带端口或区域标识的地址、未指定地址、多播地址、超长头和超过 32 跳的链均回退到真实连接来源。IPv4 映射 IPv6 先规范化,避免同一来源形成多个限流键。配置中包含非法网段时也不信任转发头。 + +控制器 `remoteIP` 和 `loginRemoteHost` 共用同一解析结果,因此验证码签发、验证、恢复和登录限流对来源的理解一致。账号维度限流保持不变。没有修改 `RemoteAddr`,首次安装来源限制、原有 TLS 模式识别和本机管理 IPC 边界保持不变。 + +上线时应仅配置真实代理网段。本服务器上游为 `127.0.0.1:8002`,可使用 `127.0.0.1/32`;仅在确实使用 IPv6 回环上游时再加入 `::1/128`。不得为了兼容而配置 `0.0.0.0/0` 或 `::/0`。Nginx 必须继续使用 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖客户端头,而非无条件信任已有转发链。若以后接入 CDN,需要重新确定代理信任边界并验收,不能直接扩大网段。 + +更正真实 IP 可能使部署前按回环地址绑定的待验证挑战失效,应重新发起验证;不得为迁移兼容而跳过 IP 绑定。该变更本身不声称已经撤销所有旧会话或完成管理员恢复验收。 + +## 已有证据 + +首轮目录:`/var/tmp/smail-review-proxy-20260918`。单元 `smail-tests-20260918-004126` 在 128 MiB 上限、独立网络、只读系统、禁止访问生产应用和管理接口的环境执行。39 个顶层针对性测试通过;浏览器专用夹具 `TestSecuritySetupBrowserFixture` 跳过,不能计作浏览器验收。运行 450 毫秒,峰值 16.5 MiB,交换为零。 + +最终候选目录:`/var/tmp/smail-review-proxy-final-20260918`。三个测试程序上传后核对 SHA-256,清单保存于 `manifest.json`,未覆盖首轮产物。 + +客户端地址单元 `smail-tests-20260918-004453`:三个顶层测试通过、无跳过;包含 26 个解析子场景及两条真实 Nginx 来源链路。测试在独立网络创建自己的 Nginx 和后端,仅连接该网络中的回环地址;不读取宿主机 Nginx 配置,不连接线上后端。来自 `127.0.0.2` 和 `127.0.0.3` 的重复伪造转发头经 Nginx 覆盖后,后端仍分别识别真实来源,连接来源保持 `127.0.0.1`。 + +完整回归单元:`smail-tests-20260918-004454`,使用 192 MiB 上限,逐用例独立进程,避免历史全局状态干扰;结果位于 `results-20260918-004454`。尚未据此签署最终发布验收。 diff --git a/docs/生产整改验收清单.md b/docs/生产整改验收清单.md index 47ea7fa..2d0ec33 100644 --- a/docs/生产整改验收清单.md +++ b/docs/生产整改验收清单.md @@ -2,6 +2,12 @@ ## 范围与结论 +### 当前候选:可信代理客户端地址整改 + +- [✓] 可信代理地址解析候选及真实隔离 Nginx 双来源验证通过,默认不信任任何代理;登录限流和安全验证共用解析,原始连接地址不变。 +- [~] 控制器和 HTTP 层完整远程回归正在执行,详情见《可信代理地址整改记录》。候选尚未构建为正式应用或部署,不能将线上代理 IP 问题标为验收完成。 +- [!] 公网恢复地址仍继承内部 8002,浏览器业务流程、管理员恢复撤销及其他生产门槛保持未完成。 + ### 当前状态:远程主机 2026 年 9 月 18 日凌晨无停机续期整改 - [✓] Nginx 已迁移到已发布证书目录,不再直接读取 ACME 签发产物;Smail 8001/8002 和其他站点配置未修改。