整改基线:封存可信代理地址解析与远程回归候选
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 00:52:10 +08:00
parent 05b244532d
commit a4280e7abc
8 changed files with 355 additions and 21 deletions

View File

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

View File

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

View File

@ -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"

View File

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

View File

@ -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("空请求应返回空地址")
}
}

View File

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

View File

@ -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`。尚未据此签署最终发布验收。

View File

@ -2,6 +2,12 @@
## 范围与结论
### 当前候选:可信代理客户端地址整改
- [✓] 可信代理地址解析候选及真实隔离 Nginx 双来源验证通过,默认不信任任何代理;登录限流和安全验证共用解析,原始连接地址不变。
- [~] 控制器和 HTTP 层完整远程回归正在执行,详情见《可信代理地址整改记录》。候选尚未构建为正式应用或部署,不能将线上代理 IP 问题标为验收完成。
- [!] 公网恢复地址仍继承内部 8002,浏览器业务流程、管理员恢复撤销及其他生产门槛保持未完成。
### 当前状态:远程主机 2026 年 9 月 18 日凌晨无停机续期整改
- [✓] Nginx 已迁移到已发布证书目录,不再直接读取 ACME 签发产物;Smail 8001/8002 和其他站点配置未修改。