fix(permissions):修复后端权限漏洞

This commit is contained in:
chengqian committed 2026-09-03 15:05:31 +08:00
1 parent 9d61abda86
commit e703c9a044
21 files changed
+233 -136

No files matched your search

+7 -3
View File
@@ -77,11 +77,15 @@ func AdminAuth(r *ghttp.Request) {
return
}
// Verify user still exists and is active
// Verify user still exists and is active.
// role 一并取出:super_admin 会短路整套鉴权(HasPermission 直接放行、
// assertSuperAdmin 与 superAdminOnlyRules 都只看它),是全站最高权级的判据,
// 因此必须以数据库为准,不能沿用签发时写进 token 的 claims.Role —— 否则给某人
// 临时提为超管再降回来后,其旧 token 在有效期内仍是超管(降权无法即时生效)。
var user *entity.SysAdminUsers
err = dao.SysAdminUsers.Ctx(r.Context()).
Where("id", claims.UserID).
Fields("status").
Fields("status, role").
Scan(&user)
if err != nil {
response.ErrorMsg(r, consts.CodeUnauthorized, consts.MsgUnauthorized)
@@ -95,7 +99,7 @@ func AdminAuth(r *ghttp.Request) {
// Set auth context
r.SetCtxVar(CtxKeyUserID, claims.UserID)
r.SetCtxVar(CtxKeyUserType, claims.UserType)
r.SetCtxVar(CtxKeyRole, claims.Role)
r.SetCtxVar(CtxKeyRole, user.Role)
r.SetCtxVar(CtxKeySessionID, claims.SessionID)
r.SetCtxVar(CtxKeyJti, claims.ID)
+44 -44
View File
@@ -10,42 +10,11 @@ import (
"github.com/qianfree/team-api/internal/response"
)
// RequirePermission returns middleware that checks if the admin user has the required permission.
func RequirePermission(permission string) func(r *ghttp.Request) {
return func(r *ghttp.Request) {
role := GetUserRole(r.Context())
userID := GetUserID(r.Context())
if role == "super_admin" {
r.Middleware.Next()
return
}
if !admin.HasPermission(r.Context(), userID, role, permission) {
response.ErrorMsg(r, consts.CodeForbidden, "缺少权限:"+permission)
return
}
r.Middleware.Next()
}
}
// RequireTenantRole returns middleware that checks if the tenant user has the required role.
func RequireTenantRole(roles ...string) func(r *ghttp.Request) {
roleSet := make(map[string]bool)
for _, r := range roles {
roleSet[r] = true
}
return func(r *ghttp.Request) {
role := GetUserRole(r.Context())
if !roleSet[role] {
response.ErrorMsg(r, consts.CodeForbidden, consts.MsgForbidden)
return
}
r.Middleware.Next()
}
}
// 说明:曾有 RequirePermission / RequireTenantRole 两个「按路由挂载」的中间件,
// 均无任何调用方,已删除。管理后台的授权统一由 AdminPermissionGuard + adminPermissionRules
// 集中判定(默认拒绝、规则完整性由 rbac_coverage_test.go 保证);租户控制台的角色校验
// 在 logic 层逐方法进行。留着一个从未挂载的 RequireTenantRole 会让人误以为
// /api/tenant 有路由级角色闸门,实际并没有。
// adminPermissionRules defines the mapping from route patterns to permission points.
// Unmapped admin routes are denied by default in AdminPermissionGuard.
@@ -139,7 +108,7 @@ var adminPermissionRules = []permissionRule{
{method: "POST", prefix: "/api/admin/wallets/", suffix: "/offline-recharge", perm: "billing:refund"},
{method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release", perm: "billing:refund"},
{method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release-all", perm: "billing:refund"},
{method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:view"},
{method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:refund"},
// ── plan 套餐管理 ──
{method: "GET", path: "/api/admin/plans", perm: "plan:view"},
@@ -154,7 +123,9 @@ var adminPermissionRules = []permissionRule{
{method: "GET", path: "/api/admin/orders/export", perm: "order:view"},
{method: "GET", prefix: "/api/admin/orders/", perm: "order:view"},
{method: "POST", prefix: "/api/admin/orders/", suffix: "/refund", perm: "order:refund"},
{method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:view"},
// 手动完成订单会走 markOrderPaidByAdmin + payment.FulfillOrder:无支付凭证直接履约
// (充值入钱包 / 发放套餐),是资金动作而非订单查看,按退款同级管控。
{method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:refund"},
// ── operation 内容运营 ──
{method: "GET", path: "/api/admin/changelogs", perm: "operation:view"},
@@ -195,7 +166,9 @@ var adminPermissionRules = []permissionRule{
// ── audit 审计日志 ──
{method: "GET", path: "/api/admin/audit/config", perm: "audit:view"},
{method: "PUT", path: "/api/admin/audit/config", perm: "audit:view"},
// 写 sys_options.audit_level(全局审计级别,可设为 none 关闭全站审计采集):
// 属于系统配置而非审计查看,否则只读档的技术支持可以自行关掉审计留痕。
{method: "PUT", path: "/api/admin/audit/config", perm: "system:edit"},
{method: "GET", path: "/api/admin/audit/operation-logs", perm: "audit:view"},
{method: "GET", path: "/api/admin/audit/operation-logs/export", perm: "audit:export"},
{method: "GET", path: "/api/admin/audit/request-logs", perm: "audit:view"},
@@ -270,10 +243,12 @@ var adminPermissionRules = []permissionRule{
// ── member 成员管理(管理后台维度) ──
{method: "GET", path: "/api/admin/members", perm: "member:view"},
{method: "POST", path: "/api/admin/members", perm: "member:import"},
{method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:view"},
{method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:view"},
{method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:view"},
{method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:view"},
// 禁用/启用/改密/解锁都是对租户成员账号的处置(改密等于接管该成员账号,
// 进而可用其 API Key 消耗租户余额),必须走写权限点,不能沿用只读的 member:view。
{method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:manage"},
{method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:manage"},
{method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:manage"},
{method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:manage"},
// ── redemption 兑换码管理 ──
{method: "GET", path: "/api/admin/redemptions", perm: "redemption:view"},
@@ -299,7 +274,6 @@ var adminPermissionRules = []permissionRule{
{method: "GET", path: "/api/admin/permissions", perm: "user:view"},
{method: "GET", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:view"},
{method: "PUT", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:edit"},
{method: "PUT", prefix: "/api/admin/users/", suffix: "/data-scopes", perm: "user:edit"},
// ── admin session and security ──
{method: "POST", path: "/api/admin/auth/logout", perm: "self:access"},
@@ -405,6 +379,16 @@ type permissionRule struct {
// 兜底保证:super_admin 在规则匹配之前短路放行,因此即便规则有遗漏也不会把系统锁死,
// 超管始终可以进入并修复。规则的完整性由 rbac_coverage_test.go 在 CI 阶段保证。
func AdminPermissionGuard(r *ghttp.Request) {
// 路径必须是规范形式:GoFrame 不会归一化重复斜杠,/api/admin//roles 会带着原样路径
// 进到 handler,而规则表按精确路径/前缀匹配,同一个 handler 就有了两种匹配结果。
// 目前默认拒绝把这类变体都挡在外面,但那是「恰好安全」——只要将来某条前缀规则的
// 权限点比精确规则弱,加一个斜杠就能降档。与其依赖这个巧合,不如直接拒收非规范路径:
// 正常客户端不会产生它,攻击者才需要它。
if !isCanonicalPath(r.URL.Path) {
response.ErrorMsg(r, consts.CodeForbidden, "非法的请求路径")
return
}
if isAdminPublicPath(r.URL.Path) {
r.Middleware.Next()
return
@@ -443,6 +427,22 @@ func AdminPermissionGuard(r *ghttp.Request) {
r.Middleware.Next()
}
// isCanonicalPath 判断路径是否为规范形式:不含空路径段("//")与 "." / ".." 段。
//
// 只做判定不做重写:重写会把「当前被默认拒绝挡住的变体」变成放行,等于放宽;
// 直接拒收既不放宽也不留下将来被利用的缝隙。
func isCanonicalPath(path string) bool {
if path == "" {
return false
}
for _, seg := range strings.Split(strings.TrimPrefix(path, "/"), "/") {
if seg == "" || seg == "." || seg == ".." {
return false
}
}
return true
}
// matchPermission finds the permission required for the given method and path.
// Matching priority: exact path > prefix+suffix > prefix-only (catch-all).
// This three-phase approach eliminates rule ordering dependencies.
+36
View File
@@ -225,6 +225,42 @@ func TestMatchedPermissionsAreGrantable(t *testing.T) {
}
}
// writeOnViewPermExceptions 列出「写方法但语义上是读」的接口白名单。
//
// 加白之前先问一句:这个接口有没有改变任何持久状态?只有答案是「没有,用 POST 纯粹是
// 因为查询参数太复杂放不进 query string」时才可以加,并写明理由。
var writeOnViewPermExceptions = map[string]string{
// 导出模型配置为 JSON:只读取并序列化现有配置,不写库。用 POST 是因为筛选条件是
// 一个可能很大的模型 ID 数组。
"POST /api/admin/models/export-json": "纯导出,不改变任何状态",
}
// TestWriteRoutesDoNotUseViewPermissions 保证写接口不会挂在只读权限点上。
//
// 这是本次权限审计里出现最多的一类缺陷:接口按 :view 配置,于是「技术支持」这类被明确
// 定位成只读的角色,实际拥有了改密、改配置、履约订单的能力。权限点的档位语义
// (read/operate/full)只有在规则表遵守它时才成立,靠人工 review 守不住这条线。
func TestWriteRoutesDoNotUseViewPermissions(t *testing.T) {
for _, r := range collectAdminRoutes(t) {
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
continue
}
path := concreteFromPattern(r.Path)
if isAdminPublicPath(path) || isSuperAdminOnly(r.Method, path) {
continue
}
if _, ok := writeOnViewPermExceptions[r.Method+" "+r.Path]; ok {
continue
}
perm := matchPermission(r.Method, path)
if strings.HasSuffix(perm, ":view") {
t.Errorf("写接口 %s %s 挂在只读权限点 %q 上:只读角色会因此获得写能力。"+
"请改挂对应的操作/完全档权限点;若该接口确实不改变任何状态,"+
"把它加进 writeOnViewPermExceptions 并说明理由", r.Method, r.Path, perm)
}
}
}
// TestRoleManagementIsSuperAdminOnly 保证角色管理的每个端点都被限定为超级管理员专属。
//
// 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条