修复公开密码重置查询泄露超级管理员身份的问题

This commit is contained in:
xuans 2026-10-01 22:39:09 +08:00
parent df0be7f555
commit 51ab0a6d0c
5 changed files with 142 additions and 2 deletions

View File

@ -0,0 +1,44 @@
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/auth"
)
type publicPasswordResetGuidance struct {
Title string `json:"title"`
Description string `json:"description"`
Action string `json:"action"`
}
// showPasswordResetGuidance 仅验证输入格式,不查询账户,也不创建重置会话。
// 公开路由必须使用此处理器,不能绑定历史的账户分类处理器。
func (s *Server) showPasswordResetGuidance(c *gin.Context) {
c.Header("Cache-Control", "no-store")
var request passwordResetResolveRequest
if err := bindJSON(c, &request, passwordResetResolveBodyLimit); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "账户信息格式无效"})
return
}
if _, err := auth.NormalizeIdentifier(request.Identifier); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入有效的用户名或绑定邮箱"})
return
}
s.configMu.RLock()
ready := s.config.SetupReady
s.configMu.RUnlock()
if !ready {
c.JSON(http.StatusConflict, gin.H{"error": "Scert 尚未完成初始化,暂不能处理密码重置"})
return
}
c.JSON(http.StatusOK, publicPasswordResetGuidance{
Title: "密码重置",
Description: "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。",
Action: "contact_administrator",
})
}

View File

@ -0,0 +1,77 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPublicPasswordResetGuidanceDoesNotEnumerateAccounts(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "owner"
cfg.Admin.Email = "owner@example.test"
server := New(Dependencies{Config: cfg})
var baseline string
for _, identifier := range []string{"owner", "owner@example.test", "OWNER", "OWNER@EXAMPLE.TEST", "not-an-account", "absent@example.test", " owner "} {
t.Run(identifier, func(t *testing.T) {
body, err := json.Marshal(map[string]string{"identifier": identifier})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/password-reset/resolve", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("公开查询应返回通用成功响应:%d %s", response.Code, response.Body.String())
}
if baseline == "" {
baseline = response.Body.String()
} else if response.Body.String() != baseline {
t.Fatalf("不同合法标识不得产生可区分的响应:%s", response.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload) != 3 || payload["action"] != "contact_administrator" || payload["title"] != "密码重置" || payload["description"] != "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。" {
t.Fatalf("公开响应必须仅包含通用处理提示:%v", payload)
}
if response.Header().Get("Cache-Control") != "no-store" {
t.Fatal("密码处理提示不得被缓存")
}
if len(response.Result().Cookies()) != 0 || len(server.authSessions) != 0 || len(server.loginPending) != 0 {
t.Fatal("公开查询不得创建会话或登录状态")
}
})
}
}
func TestPublicPasswordResetGuidancePreservesInputBoundary(t *testing.T) {
for _, ready := range []bool{false, true} {
cfg := readyTestConfig(t.TempDir())
cfg.SetupReady = ready
server := New(Dependencies{Config: cfg})
for _, body := range []string{`{}`, `{"identifier":""}`, `{"identifier":17}`, `{"identifier":"owner","unexpected":true}`, `{"identifier":"owner"}{"identifier":"owner"}`, `{"identifier":"` + strings.Repeat("a", passwordResetResolveBodyLimit) + `"}`} {
request := httptest.NewRequest(http.MethodPost, "/api/v1/password-reset/resolve", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("无效请求必须被拒绝:ready=%t status=%d", ready, response.Code)
}
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/password-reset/resolve", strings.NewReader(`{"identifier":"owner"}`))
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
want := http.StatusOK
if !ready {
want = http.StatusConflict
}
if response.Code != want {
t.Fatalf("初始化边界不正确:ready=%t status=%d", ready, response.Code)
}
}
}

View File

@ -30,7 +30,7 @@ func TestPasswordResetResolutionOnlyOffersAdministratorContact(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["account_type"] != "super_admin" || payload["action"] != "contact_administrator" {
if payload["account_type"] != nil || payload["action"] != "contact_administrator" {
t.Fatalf("超级管理员没有返回受控本机处理标识:%v", payload)
}
if description := strings.ToLower(response.Body.String()); strings.Contains(description, "scert-admin") || strings.Contains(description, "systemctl") || strings.Contains(description, "local_admin_command") {

View File

@ -202,7 +202,7 @@ func (s *Server) registerRoutes() {
s.router.POST("/api/v1/auth/login", s.requireSameOrigin, s.authLogin)
s.router.POST("/api/v1/auth/logout", s.requireSameOrigin, s.authLogout)
s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword)
s.router.POST("/api/v1/password-reset/resolve", s.resolvePasswordResetAccount)
s.router.POST("/api/v1/password-reset/resolve", s.showPasswordResetGuidance)
adminAPI := s.router.Group("/api/v1", s.requireAdmin)
userAPI := s.router.Group("/api/v1/users", s.requireAuthenticated)
userAPI.GET("", s.listUsers)

View File

@ -61,3 +61,22 @@ test("本机指引不包含可执行命令", () => {
const source = readFileSync(new URL("../components/LocalResetGuide/LocalResetGuide.vue", import.meta.url), "utf8");
assert.doesNotMatch(source, /systemctl|scert-admin|<code/iu);
});
test("公开密码重置接受不包含身份字段的通用响应", async (t) => {
const payload = {
title: "密码重置",
description: "请联系系统管理员协助处理密码重置。为保护账户安全,此页面不确认账户是否存在或所属角色。",
action: "contact_administrator",
};
const { state } = await mountComposable(t, usePasswordReset, async () => Response.json(payload));
for (const identifier of ["owner", "owner@example.test", "absent", "absent@example.test"]) {
state.identifier.value = identifier;
await state.submit();
assert.deepEqual(state.resolution.value, payload);
assert.equal(state.error.value, "");
assert.equal(state.submitting.value, false);
for (const field of ["account_type", "account_label", "reset_path", "reset_url", "token", "nonce"]) {
assert.equal(field in state.resolution.value, false);
}
}
});