This commit is contained in:
parent
05b244532d
commit
a4280e7abc
@ -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"
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
78
backend/utils/clientip/clientip.go
Normal file
78
backend/utils/clientip/clientip.go
Normal 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
|
||||
}
|
||||
68
backend/utils/clientip/clientip_test.go
Normal file
68
backend/utils/clientip/clientip_test.go
Normal 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("空请求应返回空地址")
|
||||
}
|
||||
}
|
||||
126
backend/utils/clientip/nginx_integration_test.go
Normal file
126
backend/utils/clientip/nginx_integration_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
35
docs/可信代理地址整改记录.md
Normal file
35
docs/可信代理地址整改记录.md
Normal 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`。尚未据此签署最终发布验收。
|
||||
@ -2,6 +2,12 @@
|
||||
|
||||
## 范围与结论
|
||||
|
||||
### 当前候选:可信代理客户端地址整改
|
||||
|
||||
- [✓] 可信代理地址解析候选及真实隔离 Nginx 双来源验证通过,默认不信任任何代理;登录限流和安全验证共用解析,原始连接地址不变。
|
||||
- [~] 控制器和 HTTP 层完整远程回归正在执行,详情见《可信代理地址整改记录》。候选尚未构建为正式应用或部署,不能将线上代理 IP 问题标为验收完成。
|
||||
- [!] 公网恢复地址仍继承内部 8002,浏览器业务流程、管理员恢复撤销及其他生产门槛保持未完成。
|
||||
|
||||
### 当前状态:远程主机 2026 年 9 月 18 日凌晨无停机续期整改
|
||||
|
||||
- [✓] Nginx 已迁移到已发布证书目录,不再直接读取 ACME 签发产物;Smail 8001/8002 和其他站点配置未修改。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user