Przeglądaj źródła

fix: 安全加固(封堵操作员提权、操作日志脱敏、模拟道闸告警)

- 封堵提权通道:admin_register 增加角色层级校验(618 只能建 618,888 可建 618/888,9527 全部)
- 新库种子不再授予 618 admin_register 权限;EnsureSecurityHardening 清理存量库规则
- 操作日志脱敏:密码/token/密钥字段递归替换为 ******;修复响应截断误写 Body 字段的历史 bug
- 响应统一脱敏+超 1024 截断;删除死代码 respPool
- 模拟道闸启动时 Error 日志 + 控制台横幅告警(真实道闸不动作、RFID 停用)
lq 2 tygodni temu
rodzic
commit
8cca42bc2d

+ 20 - 0
internal/api/v1/system/sys_user.go

@@ -166,6 +166,11 @@ func (b *BaseApi) Register(c *gin.Context) {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
+	// 权限加固:调用者只能创建不高于自身权限的角色,封堵操作员注册管理员账号的提权通道
+	if !canRegisterAuthority(utils.GetUserAuthorityId(c), r.AuthorityId) {
+		response.FailWithMessage("无权创建该角色账号", c)
+		return
+	}
 
 	user := &dao.SysUser{Username: r.Username, NickName: r.NickName, Password: r.Password, HeaderImg: r.HeaderImg, AuthorityId: r.AuthorityId, Enable: r.Enable, Phone: r.Phone, Email: r.Email}
 	userReturn, err := userService.Register(*user)
@@ -177,6 +182,21 @@ func (b *BaseApi) Register(c *gin.Context) {
 	response.OkWithDetailed(systemRes.SysUserResponse{User: userReturn}, "注册成功", c)
 }
 
+// canRegisterAuthority 判断调用者是否有权创建目标角色账号:
+// 9527(超级管理员)可创建全部;888(管理员)可创建 618/888;618(操作员)只能创建 618。
+func canRegisterAuthority(callerAuthority, targetAuthority uint) bool {
+	switch callerAuthority {
+	case 9527:
+		return true
+	case 888:
+		return targetAuthority == 618 || targetAuthority == 888
+	case 618:
+		return targetAuthority == 618
+	default:
+		return false
+	}
+}
+
 // ChangePassword
 // @Tags      SysUser
 // @Summary   用户修改密码

+ 31 - 0
internal/api/v1/system/sys_user_security_test.go

@@ -0,0 +1,31 @@
+package system
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestCanRegisterAuthorityRoleHierarchy(t *testing.T) {
+	cases := []struct {
+		name     string
+		caller   uint
+		target   uint
+		expected bool
+	}{
+		{"超级管理员可创建全部", 9527, 888, true},
+		{"超级管理员可创建管理员", 9527, 9527, true},
+		{"管理员可创建操作员", 888, 618, true},
+		{"管理员可创建同级", 888, 888, true},
+		{"管理员不可创建超级管理员", 888, 9527, false},
+		{"操作员只能创建操作员", 618, 618, true},
+		{"操作员不可创建管理员", 618, 888, false}, // 提权通道必须封堵
+		{"操作员不可创建超级管理员", 618, 9527, false},
+		{"未知角色一律拒绝", 0, 618, false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			require.Equal(t, tc.expected, canRegisterAuthority(tc.caller, tc.target))
+		})
+	}
+}

+ 26 - 0
internal/initialize/security_seed.go

@@ -0,0 +1,26 @@
+package initialize
+
+import (
+	"wails-app/internal/global"
+)
+
+// EnsureSecurityHardening upgrades both new and existing databases with the
+// permission rules required to close the privilege escalation hole where a
+// 618 operator could register an 888 admin account via /user/admin_register.
+//
+// 纵深防御:
+//  1. Casbin 层:撤销 618 角色的 admin_register 权限(本函数,覆盖存量数据库)。
+//  2. 业务层:Register 接口校验调用者只能创建不高于自身权限的角色
+//     (internal/api/v1/system/sys_user.go canRegisterAuthority)。
+func EnsureSecurityHardening() error {
+	db := global.GVA_DB
+	if db == nil {
+		return nil
+	}
+	// 存量库清理:删除历史上授予 618 的注册权限(新库种子已不再写入)
+	if err := db.Exec("DELETE FROM casbin_rule WHERE v0 = ? AND v1 = ? AND v2 = ?",
+		"618", "/user/admin_register", "POST").Error; err != nil {
+		return err
+	}
+	return nil
+}

+ 13 - 2
internal/initialize/seed.go

@@ -29,6 +29,12 @@ func SeedSystemData() {
 		if err := EnsureIncidentPermissions(); err != nil {
 			global.GVA_LOG.Error("incident permission upgrade failed")
 		}
+		if err := EnsureDefaultFeeConfig(); err != nil {
+			global.GVA_LOG.Error("default fee config upgrade failed")
+		}
+		if err := EnsureSecurityHardening(); err != nil {
+			global.GVA_LOG.Error("security hardening upgrade failed")
+		}
 		return
 	}
 
@@ -286,11 +292,10 @@ func SeedSystemData() {
 		V5    string `gorm:"column:v5"`
 	}
 	casbinRules := []casbinRule{
-		// 618 (普通用户)
+		// 618 (普通用户) —— 不再授予 admin_register(防止注册高权限账号提权,见 security_seed.go)
 		{"p", "618", "/base/login", "POST", "", "", ""},
 		{"p", "618", "/jwt/jsonInBlacklist", "POST", "", "", ""},
 		{"p", "618", "/menu/getMenu", "POST", "", "", ""},
-		{"p", "618", "/user/admin_register", "POST", "", "", ""},
 		{"p", "618", "/user/changePassword", "POST", "", "", ""},
 		{"p", "618", "/user/getUserInfo", "GET", "", "", ""},
 		{"p", "618", "/user/setUserAuthority", "POST", "", "", ""},
@@ -542,6 +547,12 @@ func SeedSystemData() {
 	if err := EnsureIncidentPermissions(); err != nil {
 		global.GVA_LOG.Error("incident permission seed failed")
 	}
+	if err := EnsureDefaultFeeConfig(); err != nil {
+		global.GVA_LOG.Error("default fee config seed failed")
+	}
+	if err := EnsureSecurityHardening(); err != nil {
+		global.GVA_LOG.Error("security hardening seed failed")
+	}
 
 	global.GVA_LOG.Info("system data seeded successfully")
 }

+ 11 - 1
internal/initialize/uhf.go

@@ -1,6 +1,8 @@
 package initialize
 
 import (
+	"fmt"
+
 	"go.uber.org/zap"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
@@ -12,7 +14,15 @@ import (
 func InitGateController() {
 	if global.GVA_CONFIG.System.GateSimulator {
 		parkingService.SetGateController(parkingService.NewSimulatedGateController())
-		global.GVA_LOG.Warn("道闸模拟模式已启用,不会发送真实设备指令")
+		// 生产防护:模拟模式下开闸"永远成功"且 RFID 全部停用,
+		// 误配会造成"记录显示放行成功但真实道闸从未动作",必须显著告警。
+		msg := "道闸模拟模式已启用(gate-simulator=true):真实道闸不会动作、UHF/RFID 已停用;生产环境必须关闭!"
+		global.GVA_LOG.Error(msg)
+		fmt.Println()
+		fmt.Println("======================================================================")
+		fmt.Println("!!! " + msg)
+		fmt.Println("======================================================================")
+		fmt.Println()
 		return
 	}
 	InitUHFDevices()

+ 55 - 23
internal/middleware/operation.go

@@ -9,7 +9,6 @@ import (
 	"wails-app/internal/dao"
 	"strconv"
 	"strings"
-	"sync"
 	"time"
 
 	"wails-app/internal/pkg"
@@ -22,13 +21,60 @@ import (
 
 var operationRecordService = service.ServiceGroupApp.SystemServiceGroup.OperationRecordService
 
-var respPool sync.Pool
 var bufferSize = 1024
 
-func init() {
-	respPool.New = func() interface{} {
-		return make([]byte, bufferSize)
+// sensitiveFields 操作日志脱敏字段(不区分大小写):密码、令牌、密钥、支付敏感字段。
+var sensitiveFields = map[string]bool{
+	"password": true, "old_password": true, "new_password": true,
+	"confirm_password": true, "pwd": true, "pass": true,
+	"token": true, "access_token": true, "refresh_token": true, "jwt": true,
+	"secret": true, "private_key": true,
+}
+
+// maskSensitiveJSON 对 JSON 文本中的敏感字段值脱敏(非 JSON 原样返回)。
+func maskSensitiveJSON(data []byte) []byte {
+	var obj interface{}
+	if err := json.Unmarshal(data, &obj); err != nil {
+		return data
 	}
+	out, err := json.Marshal(maskValue(obj))
+	if err != nil {
+		return data
+	}
+	return out
+}
+
+// maskValue 递归脱敏:命中敏感字段名时值替换为 "******"(空值保留)。
+func maskValue(v interface{}) interface{} {
+	switch value := v.(type) {
+	case map[string]interface{}:
+		for key, child := range value {
+			if sensitiveFields[strings.ToLower(key)] {
+				if child != nil {
+					value[key] = "******"
+				}
+				continue
+			}
+			value[key] = maskValue(child)
+		}
+		return value
+	case []interface{}:
+		for i := range value {
+			value[i] = maskValue(value[i])
+		}
+		return value
+	default:
+		return v
+	}
+}
+
+// maskResponse 响应体脱敏并截断(修复旧代码只对下载类响应截断、且误写 record.Body 字段的问题)。
+func maskResponse(resp string) string {
+	masked := maskSensitiveJSON([]byte(resp))
+	if len(masked) > bufferSize {
+		return "[超出记录长度]"
+	}
+	return string(masked)
 }
 
 func OperationRecord() gin.HandlerFunc {
@@ -79,10 +125,11 @@ func OperationRecord() gin.HandlerFunc {
 		if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
 			record.Body = "[文件]"
 		} else {
-			if len(body) > bufferSize {
+			masked := maskSensitiveJSON(body)
+			if len(masked) > bufferSize {
 				record.Body = "[超出记录长度]"
 			} else {
-				record.Body = string(body)
+				record.Body = string(masked)
 			}
 		}
 
@@ -99,22 +146,7 @@ func OperationRecord() gin.HandlerFunc {
 		record.ErrorMessage = c.Errors.ByType(gin.ErrorTypePrivate).String()
 		record.Status = c.Writer.Status()
 		record.Latency = latency
-		record.Resp = writer.body.String()
-
-		if strings.Contains(c.Writer.Header().Get("Pragma"), "public") ||
-			strings.Contains(c.Writer.Header().Get("Expires"), "0") ||
-			strings.Contains(c.Writer.Header().Get("Cache-Control"), "must-revalidate, post-check=0, pre-check=0") ||
-			strings.Contains(c.Writer.Header().Get("Content-Type"), "application/force-download") ||
-			strings.Contains(c.Writer.Header().Get("Content-Type"), "application/octet-stream") ||
-			strings.Contains(c.Writer.Header().Get("Content-Type"), "application/vnd.ms-excel") ||
-			strings.Contains(c.Writer.Header().Get("Content-Type"), "application/download") ||
-			strings.Contains(c.Writer.Header().Get("Content-Disposition"), "attachment") ||
-			strings.Contains(c.Writer.Header().Get("Content-Transfer-Encoding"), "binary") {
-			if len(record.Resp) > bufferSize {
-				// 截断
-				record.Body = "超出记录长度"
-			}
-		}
+		record.Resp = maskResponse(writer.body.String())
 
 		if err := operationRecordService.CreateSysOperationRecord(record); err != nil {
 			global.GVA_LOG.Error("create operation record error:", zap.Error(err))

+ 48 - 0
internal/middleware/operation_mask_test.go

@@ -0,0 +1,48 @@
+package middleware
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestMaskSensitiveJSONMasksPasswordAndTokenFields(t *testing.T) {
+	body := []byte(`{"username":"admin","password":"secret123","old_password":"old","new_password":"new1","token":"jwt-token","remark":"ok","nested":{"password":"x","value":1},"items":[{"pass":"p","name":"a"}]}`)
+
+	masked := string(maskSensitiveJSON(body))
+
+	// 敏感字段脱敏
+	require.NotContains(t, masked, "secret123")
+	require.NotContains(t, masked, "jwt-token")
+	require.Contains(t, masked, "******")
+	// 非敏感字段保留
+	require.Contains(t, masked, `"username":"admin"`)
+	require.Contains(t, masked, `"remark":"ok"`)
+	require.Contains(t, masked, `"value":1`)
+	require.Contains(t, masked, `"name":"a"`)
+}
+
+func TestMaskSensitiveJSONFieldNamesCaseInsensitive(t *testing.T) {
+	body := []byte(`{"Password":"UPPER","New_Password":"x"}`)
+	masked := string(maskSensitiveJSON(body))
+	require.NotContains(t, masked, "UPPER")
+	require.Contains(t, masked, "******")
+}
+
+func TestMaskSensitiveJSONPassesThroughNonJSON(t *testing.T) {
+	body := []byte(`not a json body`)
+	require.Equal(t, string(body), string(maskSensitiveJSON(body)))
+}
+
+func TestMaskResponseTruncatesLongContent(t *testing.T) {
+	long := strings.Repeat("x", 5000)
+	// JSON 数组包裹后仍超长 → 截断标记
+	resp := maskResponse(`{"data":"` + long + `"}`)
+	require.Equal(t, "[超出记录长度]", resp)
+}
+
+func TestMaskResponseKeepsShortContent(t *testing.T) {
+	resp := maskResponse(`{"code":0,"msg":"ok"}`)
+	require.Equal(t, `{"code":0,"msg":"ok"}`, resp)
+}