From 667f76924e6d2cb4646ad7207768664c03b95d54 Mon Sep 17 00:00:00 2001 From: xuans Date: Tue, 15 Sep 2026 21:24:18 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=90=E5=88=B6=E8=AE=A4=E8=AF=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=A2=9E=E9=95=BF=E5=B9=B6=E4=BC=98=E5=8C=96=E9=98=BF?= =?UTF-8?q?=E9=87=8C=E4=BA=91=E9=BB=98=E8=AE=A4=E5=85=A5=E5=8F=A3=E5=92=8C?= =?UTF-8?q?=E6=9D=83=E9=99=90=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dnsprovider/aliyun_authorization.go | 2 +- .../dnsprovider/aliyun_authorization_test.go | 4 +- backend/internal/httpapi/auth.go | 38 +++-- backend/internal/httpapi/auth_retention.go | 27 ++++ .../internal/httpapi/auth_retention_test.go | 134 ++++++++++++++++++ backend/internal/httpapi/server.go | 1 + doc/Scert开发任务清单.md | 21 +++ doc/全面审查测试与优化记录.md | 125 ++++++++++++++++ frontend/src/views/SetupWizard/SetupWizard.js | 2 +- .../src/views/SetupWizard/SetupWizard.test.js | 13 ++ 10 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 backend/internal/httpapi/auth_retention.go create mode 100644 backend/internal/httpapi/auth_retention_test.go create mode 100644 doc/全面审查测试与优化记录.md diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index 6abd8a6..df98098 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -358,7 +358,7 @@ func DiagnoseAliyunError(err error, domainName string) string { msg := err.Error() lowered := strings.ToLower(msg) if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "forbidden.ram") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") { - return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/%s 的操作权限(Action: alidns:*)", domainName, domainName) + return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 所需的 DNS 操作权限。请管理员核对实际托管区资源 acs:alidns:*:*:domain/%s,按失败操作补充必要的读取、新增或删除权限;不要直接扩大为全部操作或全部域名权限", domainName, domainName) } if strings.Contains(lowered, "nosuchdomain") || strings.Contains(lowered, "invaliddomainname.noexist") { return fmt.Sprintf("域名 %s 未在当前阿里云账号下添加 DNS 托管,请先在阿里云云解析 DNS 控制台添加此域名", domainName) diff --git a/backend/internal/dnsprovider/aliyun_authorization_test.go b/backend/internal/dnsprovider/aliyun_authorization_test.go index e761007..b28ea85 100644 --- a/backend/internal/dnsprovider/aliyun_authorization_test.go +++ b/backend/internal/dnsprovider/aliyun_authorization_test.go @@ -135,12 +135,12 @@ func TestDiagnoseAliyunError(t *testing.T) { { err: errors.New("SDKError: Code: Forbidden.RAM, Message: User not authorized to operate on the specified resource."), domain: "example.com", - expected: "当前阿里云 RAM 账号未获得域名 example.com 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/example.com 的操作权限(Action: alidns:*)", + expected: "当前阿里云 RAM 账号未获得域名 example.com 所需的 DNS 操作权限。请管理员核对实际托管区资源 acs:alidns:*:*:domain/example.com,按失败操作补充必要的读取、新增或删除权限;不要直接扩大为全部操作或全部域名权限", }, { err: errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized."), domain: "solit.cn", - expected: "当前阿里云 RAM 账号未获得域名 solit.cn 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/solit.cn 的操作权限(Action: alidns:*)", + expected: "当前阿里云 RAM 账号未获得域名 solit.cn 所需的 DNS 操作权限。请管理员核对实际托管区资源 acs:alidns:*:*:domain/solit.cn,按失败操作补充必要的读取、新增或删除权限;不要直接扩大为全部操作或全部域名权限", }, { err: errors.New("SDKError: Code: NoSuchDomain, Message: The specified domain name does not exist."), diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go index c58a086..c63dff1 100644 --- a/backend/internal/httpapi/auth.go +++ b/backend/internal/httpapi/auth.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "net" "net/http" @@ -90,6 +91,12 @@ func (s *Server) authLogin(c *gin.Context) { } expiresAt := minTime(now.Add(authSessionLifetime), absoluteExpiresAt) s.authMu.Lock() + s.pruneAuthSessionsLocked(now) + if len(s.authSessions) >= authStateCapacity { + s.authMu.Unlock() + s.writeError(c, http.StatusServiceUnavailable, "登录会话数量已达上限,请稍后重试", nil) + return + } s.authSessions[token] = authSession{ UserID: userID, Username: account.Username, @@ -189,15 +196,20 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) { return authSession{}, false } newExpiresAt := minTime(now.Add(authSessionLifetime), session.AbsoluteExpiresAt) - if newExpiresAt.After(session.ExpiresAt) { - session.ExpiresAt = newExpiresAt - s.authMu.Lock() - if current, exists := s.authSessions[token]; exists { - current.ExpiresAt = newExpiresAt - s.authSessions[token] = current - session = current - } + s.authMu.Lock() + current, exists := s.authSessions[token] + if !exists { s.authMu.Unlock() + return authSession{}, false + } + refreshed := newExpiresAt.After(current.ExpiresAt) + if refreshed { + current.ExpiresAt = newExpiresAt + s.authSessions[token] = current + } + session = current + s.authMu.Unlock() + if refreshed { s.setAuthCookie(c, token, session.ExpiresAt) } return session, true @@ -270,9 +282,10 @@ func (s *Server) allowLogin(key string) bool { now := s.now() s.loginMu.Lock() defer s.loginMu.Unlock() + s.pruneLoginAttemptsLocked(now) attempt, ok := s.loginAttempts[key] if !ok { - return true + return len(s.loginAttempts) < authStateCapacity } if !attempt.BlockedTill.IsZero() && attempt.BlockedTill.After(now) { return false @@ -287,6 +300,10 @@ func (s *Server) recordLoginFailure(key string) { now := s.now() s.loginMu.Lock() defer s.loginMu.Unlock() + s.pruneLoginAttemptsLocked(now) + if _, exists := s.loginAttempts[key]; !exists && len(s.loginAttempts) >= authStateCapacity { + return + } attempt := s.loginAttempts[key] if attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow { attempt = loginAttempt{WindowStart: now} @@ -323,7 +340,8 @@ func loginAttemptKey(request *http.Request, identifier string) string { remote = host } } - return strings.ToLower(strings.TrimSpace(remote + "|" + identifier)) + digest := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(identifier)))) + return strings.ToLower(strings.TrimSpace(remote)) + "|" + hex.EncodeToString(digest[:]) } func newAuthToken() (string, error) { diff --git a/backend/internal/httpapi/auth_retention.go b/backend/internal/httpapi/auth_retention.go new file mode 100644 index 0000000..7ab2a3c --- /dev/null +++ b/backend/internal/httpapi/auth_retention.go @@ -0,0 +1,27 @@ +package httpapi + +import "time" + +const authStateCapacity = 4096 + +// 调用方持有认证锁;会话表有容量上限,不淘汰仍然有效的用户会话。 +func (s *Server) pruneAuthSessionsLocked(now time.Time) { + for token, session := range s.authSessions { + if !session.ExpiresAt.After(now) || (!session.AbsoluteExpiresAt.IsZero() && !session.AbsoluteExpiresAt.After(now)) { + delete(s.authSessions, token) + } + } +} + +// 调用方持有限流锁;定期回收且保留有效封禁,避免随机账号使状态无限增长。 +func (s *Server) pruneLoginAttemptsLocked(now time.Time) { + if !s.loginPrunedAt.IsZero() && !now.Before(s.loginPrunedAt) && now.Sub(s.loginPrunedAt) < time.Minute { + return + } + for key, attempt := range s.loginAttempts { + if !attempt.BlockedTill.After(now) && (attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow) { + delete(s.loginAttempts, key) + } + } + s.loginPrunedAt = now +} diff --git a/backend/internal/httpapi/auth_retention_test.go b/backend/internal/httpapi/auth_retention_test.go new file mode 100644 index 0000000..1afd0f1 --- /dev/null +++ b/backend/internal/httpapi/auth_retention_test.go @@ -0,0 +1,134 @@ +package httpapi + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestLoginPrunesUnvisitedExpiredState(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.Admin.Username = "admin" + cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123") + server := New(Dependencies{Config: cfg}) + now := time.Now() + server.now = func() time.Time { return now } + server.authSessions["expired"] = authSession{ExpiresAt: now.Add(-time.Hour)} + server.authSessions["absolute-expired"] = authSession{ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(-time.Second)} + server.loginAttempts["old-key"] = loginAttempt{WindowStart: now.Add(-time.Hour)} + server.loginAttempts["blocked-key"] = loginAttempt{WindowStart: now.Add(-time.Hour), BlockedTill: now.Add(time.Minute)} + response := loginAuthTest(t, server, "admin", "AdminPass123", false) + if response.Code != http.StatusOK { + t.Fatalf("登录失败:%d", response.Code) + } + if len(server.authSessions) != 1 { + t.Fatal("新登录必须清理不会再被访问的过期会话") + } + if _, exists := server.loginAttempts["old-key"]; exists { + t.Fatal("新登录必须清理过期限流键") + } + if _, exists := server.loginAttempts["blocked-key"]; !exists { + t.Fatal("清理不能缩短仍然有效的封禁期") + } +} + +func TestSessionCapacityDoesNotEvictActiveSessions(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.Admin.Username = "admin" + cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123") + server := New(Dependencies{Config: cfg}) + now := time.Now() + server.now = func() time.Time { return now } + for index := 0; index < authStateCapacity; index++ { + server.authSessions[fmt.Sprint(index)] = authSession{ExpiresAt: now.Add(time.Minute)} + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`)) + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable || len(response.Result().Cookies()) != 0 || len(server.authSessions) != authStateCapacity { + t.Fatal("会话容量已满时应明确拒绝,不得踢出其他有效会话或设置新 Cookie") + } + now = now.Add(2 * time.Minute) + response = loginAuthTest(t, server, "admin", "AdminPass123", false) + if response.Code != http.StatusOK || len(server.authSessions) != 1 { + t.Fatal("过期会话清理后必须恢复登录能力") + } +} + +type pausedSessionRepository struct { + repository.UserRepository + started chan struct{} + release chan struct{} + user repository.User +} + +func (r *pausedSessionRepository) GetUserByID(ctx context.Context, _ string) (repository.User, error) { + close(r.started) + select { + case <-r.release: + return r.user, nil + case <-ctx.Done(): + return repository.User{}, ctx.Err() + } +} + +func TestConcurrentLogoutWinsOverPausedSessionRefresh(t *testing.T) { + users := &pausedSessionRepository{ + started: make(chan struct{}), release: make(chan struct{}), + user: repository.User{ID: "operator", Username: "operator", Email: "operator@example.test", PasswordHash: "test-hash", Role: repository.UserRoleUser, Status: repository.UserStatusActive}, + } + server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users}) + now := time.Now() + server.authSessions["paused"] = authSession{UserID: users.user.ID, Username: users.user.Username, Email: users.user.Email, Role: users.user.Role, PasswordHash: users.user.PasswordHash, ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour)} + cookie := &http.Cookie{Name: authCookieName, Value: "paused"} + done := make(chan *httptest.ResponseRecorder, 1) + go func() { done <- authSessionRequest(server, cookie) }() + select { + case <-users.started: + case <-time.After(time.Second): + close(users.release) + t.Fatal("会话未进入账号校验阶段") + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + request.AddCookie(cookie) + server.Handler().ServeHTTP(httptest.NewRecorder(), request) + close(users.release) + response := <-done + if response.Code != http.StatusUnauthorized || len(response.Result().Cookies()) != 0 { + t.Fatal("并发退出后的旧请求不能继续刷新会话或设置 Cookie") + } +} + +func TestLoginStateHasFiniteCapacity(t *testing.T) { + server := New(Dependencies{Config: readyTestConfig(t.TempDir())}) + for index := 0; index < 20000; index++ { + key := fmt.Sprintf("unique-%d", index) + if server.allowLogin(key) { + server.recordLoginFailure(key) + } + } + if len(server.loginAttempts) > 4096 { + t.Fatalf("失败登录记录无界增长:%d", len(server.loginAttempts)) + } + if server.allowLogin("new-key-after-capacity") { + t.Fatal("限流表已满时不能继续接受未知登录键") + } +} + +func TestLoginAttemptKeyDoesNotRetainLargeIdentifier(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) + request.RemoteAddr = "127.0.0.1:12345" + identifier := fmt.Sprintf("%016000d", 1) + if len(loginAttemptKey(request, identifier)) > 128 { + t.Fatal("限流键不应保留大体积用户输入") + } + if loginAttemptKey(request, " ADMIN ") != loginAttemptKey(request, "admin") { + t.Fatal("账号规范化规则不能因散列键改变") + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 6492bce..c90fe75 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -106,6 +106,7 @@ type Server struct { authMu sync.RWMutex loginAttempts map[string]loginAttempt loginMu sync.Mutex + loginPrunedAt time.Time now func() time.Time httpServer *http.Server router *gin.Engine diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 1be8ccb..4e85161 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3274,3 +3274,24 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - `[!]` 真实 DNS 服务商、Let’s Encrypt Production、远程 SSH 部署、Nginx 反向代理、生产 HTTPS、Windows Service、Windows ACL、MySQL 和真实防火墙链路仍需目标环境验收。 - `[!]` 工作区已有的 `desktop/desktop.exe`、`desktop/frontend/dist/index.html`、`dist/`、`backend/.patch*` 和 `backend/*.exe` 未删除、未纳入本轮源码提交。 - `[!]` 工作区已有的 `desktop/desktop.exe`、`desktop/frontend/dist/index.html`、`dist/`、`backend/.patch*` 和 `backend/*.exe` 未删除、未纳入源码提交。 + +## 八十七、请求安全、DNS 状态与认证稳定性审查(2026-09-15) + +详细记录和证据范围见 `全面审查测试与优化记录.md`,本节不覆盖旧轮的实机验收结论。 + +- `[✓]` 恢复任务与工作区,分清管理部署机和推送目标机;保留禁止修改 `pcux.cn` 的边界。 +- `[✓]` 桌面模块同步 SSH 安全依赖,恢复只读模块测试。 +- `[✓]` 登录、退出和首次初始化增加跨源写入防护。 +- `[✓]` 长操作采用有限上下文与独立响应期限,真实套接字回归通过。 +- `[✓]` 初始化改用专用互斥锁,签发不再阻塞配置读取,并发初始化拒绝。 +- `[✓]` DNS 验证结果与当前输入、请求代次和有效期绑定,防止旧响应覆盖。 +- `[✓]` 嵌套续期、云 API 报告脱敏,保留清理失败等重要状态。 +- `[✓]` 认证状态过期回收、容量上限、固定长度限流键和并发退出回归。 +- `[✓]` 新初始化默认阿里云;兼容已保存服务商,不建议通配全部权限。 +- `[✓]` 后端全量测试、竞态检测、静态检查、模块校验、漏洞复扫;最终覆盖率 64.0%。 +- `[✓]` 前端 12 项自动化测试、生产构建、全依赖审计;桌面模块最终测试通过。 +- `[✓]` Linux 和 Windows 安装脚本语法检查,未执行安装器。 +- `[~]` 独立 Linux 发布候选、源码提交与归档哈希复核。 +- `[!]` 本地服务启动被执行策略拒绝,本轮实际浏览器验收未执行,未尝试绕过。 +- `[!]` 写权限探测逐级尝试父托管区的边界需专项整改;只读精准检查不等于写路径安全完成。 +- `[!]` 真实初始化、正式 ACME、HTTPS/SNI、业务推送、升级回退及目标平台验收仍未完成。 diff --git a/doc/全面审查测试与优化记录.md b/doc/全面审查测试与优化记录.md new file mode 100644 index 0000000..1fba52f --- /dev/null +++ b/doc/全面审查测试与优化记录.md @@ -0,0 +1,125 @@ +# 全面审查、测试与优化记录 + +## 一、范围与需求确认 + +日期:2026-09-15。本轮从北京时间 20:47 开始,承接“全面审查、测试、优化”的要求。 + +本轮按既有需求执行,不扩大测试授权: + +- 保留多用户;新安装优先阿里云,既有服务商配置保持兼容。 +- 管理部署环境为 `47.108.78.12`;`8.137.23.95` 仅作为推送测试目标,不能混用。 +- 禁止修改 `pcux.cn` 及其子域的 DNS、证书、私钥和站点;不重载共享 Nginx。 +- 自动化测试使用各自隔离的临时数据,不清空现有业务数据库。 +- 不调用真实 RAM API,不执行全局域名发现,不把历史可见范围当成当前可写授权。 +- 不删除已有构建、补丁、工作区文件,不把含凭据的辅助脚本加入 Git。 +- 中文文件通过补丁修改;分阶段中文提交、显式暂存、推送并验证。 + +## 二、任务清单 + +| 状态 | 任务 | +| --- | --- | +| `[✓]` | 恢复任务、核对工作区及两台主机边界 | +| `[✓]` | 桌面模块同步后端 SSH 安全依赖,恢复只读模块测试 | +| `[✓]` | 认证和初始化跨源写请求防护及回归 | +| `[✓]` | 同步长操作的有限上下文、响应期限及真实套接字回归 | +| `[✓]` | 初始化锁粒度优化及并发提交回归 | +| `[✓]` | 前端 DNS 旧响应、输入失效、过期令牌和卸载竞态修复 | +| `[✓]` | 续期及阿里云报告嵌套错误脱敏 | +| `[✓]` | 认证状态容量、过期回收、并发退出补强及最终全量复核 | +| `[✓]` | 默认阿里云与最小权限提示优化;前端 12 项测试通过 | +| `[✓]` | 最终全量测试、竞态检测、静态检查及漏洞复扫 | +| `[~]` | 独立发布包、源码提交和哈希复核 | +| `[!]` | 实际浏览器页面验收:本地服务启动被执行策略拒绝,未绕过 | +| `[!]` | 真实初始化、正式 ACME、受控业务推送及生产准入 | + +## 三、复现与修复 + +### 1. 跨源登录、退出与初始化写入 + +原登录与退出接受跨源 `Origin` 或 `Referer`,并允许 `text/plain` 携带 JSON。跨源回归在修复前返回 200。仅限制初始化请求来自回环地址,也不能替代浏览器来源校验。 + +登录、退出、初始化提交和初始化 DNS 测试加入来源中间件;跨源返回 403,不设置或清除 Cookie,也不删除已有会话。同源和无来源头的既有 CLI/API 访问保持兼容。 + +### 2. 长操作完成后客户端却读到 EOF + +普通 HTTP 写期限为 30 秒,而 DNS 等待和证书工作流明显更长。使用真实回环套接字,把普通写期限缩短为 50 毫秒、模拟 DNS 工作流延迟 150 毫秒,原实现稳定出现 EOF。 + +增加按路由配置的长操作中间件:DNS 使用其验证期限,证书操作上限 30 分钟,初始化外层 12 分钟。通过 `ResponseController` 调整响应写期限,并留出 2 分钟处理清理、回滚和结果序列化。普通请求的超时保护不变。新增取消后的明确响应测试。 + +**反向代理仍必须单独设置足够的读响应期限**。服务器放宽写期限不会自动改变 Nginx、负载均衡或客户端超时;本轮未修改远程代理配置。 + +### 3. 初始化阻塞所有配置读取 + +初始化原来在证书签发期间持续持有配置写锁。改为专用初始化互斥锁,加上开始和结束时的短配置锁。并发初始化返回 409;在模拟签发阻塞期间,状态请求仍可返回。 + +### 4. DNS 输入与旧响应串线 + +初始化及系统设置存在旧请求覆盖新输入、新结果的问题;系统设置对过期令牌和新增凭据检查不足。最初六个新增测试在修复前失败。 + +采用请求代次与同步输入监听,只有当前代次可更新结果、错误和加载状态。输入变化、关闭设置、组件卸载均使在途结果失效。新增配置必须有未过期的服务端验证令牌;未修改已保存 DNS 配置时仍可保存其他设置。 + +测试直接运行真实 Vue 组合式逻辑、监听和生命周期,模拟网络,不引入新 npm 依赖。**这些测试不是页面渲染或真实云 API 验收。** + +### 5. 嵌套错误泄露 + +原 `RenewalReport.Errors`、阿里云报告 `Diagnosis` 和逐域 `Error` 能把底层错误带回客户端。新增哨兵错误测试在修复前失败。 + +保留失败数量、域名及写入和清理状态,返回安全中文诊断;未知 SDK 错误、受保护凭据读取错误不再直接拼接到响应。写入成功但清理失败仍明确显示 `writable=true`、`cleanup=false`、`verified=false`,不把脱敏误做成成功。 + +### 6. 登录状态长期增长和并发退出 + +三个新测试证明:过期且不再访问的会话未清理;随机账号可持续增加限流表;大体积账号输入直接留在限流键中。 + +改为: + +- 新登录前回收过期会话,认证会话和失败登录表各设 4096 项上限。 +- 限流表按请求触发、至少间隔一分钟回收过期项,不提前解除有效封禁。 +- 账号规范化后散列为固定长度键,避免存储整段大输入。 +- 会话表满时返回 503,不踢出有效用户;限流表满时未知键返回 429,不能悄悄绕过限制。 +- 用户校验返回后再次检查会话存在性,避免已经完成的退出被旧刷新请求继续使用。 +- 增加会话容量恢复和暂停账号查询期间并发退出的回归。 + +容量上限是内存保护,不是完整抗攻击方案。高并发认证、全局容量耗尽时正常用户可用性,以及可信代理下的来源限流,还需独立负载与攻击流量验收。 + +### 7. 阿里云入口与提示 + +全新初始化默认 `aliyun`,已保存的服务商仍保留。权限拒绝提示不再建议直接授予 `Action: alidns:*`,而是核对实际托管区并按失败动作补充必要权限,不扩大至所有域名。 + +这不代表整个产品已完成阿里云专用化。其他服务商的 UI 和兼容实现未删除。 + +## 四、已收取的验证证据 + +- 第一阶段后端全量测试、全量竞态检测、`go vet` 和模块校验通过;语句覆盖率 63.9%。 +- 桌面模块依赖原来在 `-mod=readonly` 下要求更新;同步 `golang.org/x/crypto v0.56.0` 后测试、静态检查、模块校验通过。 +- 后端、桌面漏洞扫描均为零调用可达漏洞、零已导入受影响包。模块层仍提示 `GO-2026-5932`:未使用的 `golang.org/x/crypto/openpgp` 包,无修复版本。不能写成“依赖树完全无漏洞提示”。 +- 第二阶段认证及 DNS 包测试通过,新增容量及并发测试纳入最终全量复跑。 +- 前端最终 `npm test` 为 12/12,通过全依赖及生产依赖审计,均零漏洞。 +- 最新产品前端生产构建通过,输出到全新 `.runtime/review-20260915-2115/install/web`,未覆盖旧产物。 +- Linux 安装脚本 `bash -n`、Windows 安装脚本 PowerShell 语法解析通过;未执行安装器。 +- 本地服务后台启动调用被执行策略拒绝。没有换启动方式绕过,没有继续启动浏览器,没有本轮实际页面截图或页面验收结论。 + +21:23 收取最终后端复跑:全量测试、全量竞态检测、`go vet`、`go mod verify`、漏洞复扫均退出零。最终语句覆盖率 **64.0%**,HTTP 包 **70.6%**。桌面模块针对最新后端的只读模块测试、静态检查、模块校验也全部通过。 + +中间一次容量测试误用了“必须登录成功”的旧测试辅助函数,把预期 503 当作失败;已改为直接发 HTTP 请求断言 503,再验证过期回收后登录恢复。最终结果来自修正后完整重跑,不使用失败运行的覆盖率文件。 + +候选提交与发布包结果在本文件末尾追加,以最后一次成功记录为准。 + +## 五、仍需整改和生产验收的项目 + +### 发布阻塞 + +1. **阿里云写探测授权边界**:`AliyunAuthorizationChecker.Verify` 仍会从目标向父级候选逐级试写。应由用户明确托管区与允许操作范围,任何父区或公共后缀都不能因自动推断而获得操作授权。现有只读 `Inspect` 已是精确托管区模式,但不等于写路径已整改。 +2. **当前管理环境未完成初始化和正式 TLS**:上轮文档记载 `/readyz=503 setup_incomplete`,正式 ACME 连接超时。本轮没有远程刷新,不能当作已解决或确认当前仍然如此。 +3. **新代码未实机安装验收**:需要维护窗口、完整备份、受控升级、真实初始化、浏览器 HTTPS/SNI、正式证书链及回滚演练。 +4. **真实业务推送未验收**:隔离 SSH/SFTP 事务测试不等于已有业务服务的实际证书读取、配置校验和授权 reload;`pcux.cn` 禁止修改仍然有效。 + +### 后续安全与质量工作 + +- 会话有效性校验对缺少 `UserID` 或 `PasswordHash` 的内部会话仍有兼容分支;真实登录会填写这两项,多个旧测试直接注入不完整会话。应先改测试夹具,再使生产分支严格失败关闭,不能把其当前状态称作完整认证审计通过。 +- 长请求需要反向代理和实际客户端超时验证;后续宜评估异步任务接口,而不是继续提高所有请求的全局超时。 +- 多用户、高并发、容量饱和与长时间运行尚未压力验收。 +- Windows Service、Wails 安装包、ACL、MySQL 和真实防火墙链路需要各自目标平台验收。 + +## 六、结论 + +本轮完成了多项可复现缺陷修复和自动化回归,提升了发布候选质量;**不构成“全部开发完成”或“生产验收通过”**。尤其是用户强调的部分域名授权边界,应作为正式写入和生产准入前的优先整改项,而不是用全量测试通过替代。 diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js index 5ab6614..b57aafb 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.js +++ b/frontend/src/views/SetupWizard/SetupWizard.js @@ -75,7 +75,7 @@ export function useSetupWizard(props, emit) { email_retry_count: 3, email_retry_intervals_secs: "30,120,600", recipients: "", - dns_provider: props.status.dns_provider || "cloudflare", + dns_provider: props.status.dns_provider || "aliyun", propagation_timeout_secs: props.status.propagation_timeout_secs || 900, propagation_polling_interval_secs: props.status.propagation_polling_interval_secs || 10, diff --git a/frontend/src/views/SetupWizard/SetupWizard.test.js b/frontend/src/views/SetupWizard/SetupWizard.test.js index 25c68a8..204e3de 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.test.js +++ b/frontend/src/views/SetupWizard/SetupWizard.test.js @@ -4,6 +4,19 @@ import { nextTick } from "vue"; import { useSetupWizard } from "./SetupWizard.js"; import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js"; +test("全新初始化默认选择阿里云", async (t) => { + const { state } = await mountComposable(t, () => useSetupWizard({ status: {} }, () => {}), + async () => Response.json({ items: [aliyunProvider] })); + assert.equal(state.form.value.dns_provider, "aliyun"); +}); + +test("已有初始化服务商选择保持兼容", async (t) => { + const provider = { ...aliyunProvider, id: "cloudflare" }; + const { state } = await mountComposable(t, () => useSetupWizard({ status: { dns_provider: "cloudflare" } }, () => {}), + async () => Response.json({ items: [provider, aliyunProvider] })); + assert.equal(state.form.value.dns_provider, "cloudflare"); +}); + async function setup(t, request) { const { state, unmount } = await mountComposable(t, () => useSetupWizard({ status: { dns_provider: "aliyun" } }, () => {}), async (path) => path.endsWith("/providers") ? Response.json({ items: [aliyunProvider] }) : request());