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

This commit is contained in:
chengqian committed 2026-09-03 15:44:37 +08:00
1 parent e703c9a044
commit 644097fa0c
9 files changed
+297 -14

No files matched your search

+2
View File
@@ -142,6 +142,8 @@ type OpenKeyItem struct {
type OpenKeyCreateReq struct {
g.Meta `path:"/v1/keys" method:"post" mime:"json" tags:"开放平台API" summary:"创建API Key"`
Name string `json:"name" v:"required|length:2,100#请输入Key名称|名称长度2-100" dc:"Key名称"`
ProjectID int64 `json:"project_id" dc:"关联项目 ID(可选;填写后该 Key 随项目归档一并失效)"`
Scope string `json:"scope" d:"full" v:"in:full,chat_only,embeddings_only,images_only,read_only#权限范围无效" dc:"权限范围"`
ModelScopes []string `json:"model_scopes" dc:"可用模型列表(为空则不限)"`
QuotaLimit float64 `json:"quota_limit" d:"0" dc:"额度上限(0=不限)"`
}
+6 -1
View File
@@ -21,6 +21,12 @@ type OpenAppListRes struct {
PageSize int `json:"page_size"`
}
// OpenAppItem 是应用列表项。
//
// 注:opn_apps.is_sandbox 列仍在,但整个系统没有任何一处读它,创建/更新接口也从未提供过
// 入参 —— 它恒为 false,是一个只在界面上挂着「沙箱」徽标、实际拿的却是生产权限的假承诺,
// 故不在此暴露。要让沙箱成立,必须先定义其语义(独立数据域?只读?独立配额?)并在
// CheckOpenPermission / 各 handler 中真正强制,再恢复该字段与配置入口。
type OpenAppItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
@@ -28,7 +34,6 @@ type OpenAppItem struct {
AppID string `json:"app_id"`
Permissions []string `json:"permissions"`
Status string `json:"status"`
IsSandbox bool `json:"is_sandbox"`
RateLimit int `json:"rate_limit"`
LastUsedAt string `json:"last_used_at,omitempty"`
CreatedAt string `json:"created_at"`
+5 -1
View File
@@ -277,9 +277,13 @@ var (
})
// Open Platform API — HMAC-SHA256 authentication
// 中间件与 /api/tenant 对齐:演示模式同样要拦住写操作(否则演示部署可经开放平台
// 真实改数据),维护模式头同样要下发,幂等同样要生效 —— 开放平台是对外的、
// 天然会重试的接口,没有幂等语义比控制台更危险。
// Idempotency 必须排在 OpenPlatformAuth 之后:它按认证主体隔离幂等键。
s.Group("/api/open", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.MiddlewareHandlerResponse)
group.Middleware(middleware.OpenPlatformAuth)
group.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.OpenPlatformAuth, middleware.Idempotency)
group.Bind(openController.NewV1())
})
+116 -5
View File
@@ -137,6 +137,15 @@ func (s *sOpen) OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateRe
return nil, err
}
// 团队开关与席位上限:控制台的 InviteMember 两者都校验,开放平台此前一项都没有,
// 于是买 10 个席位的租户可以从这里建出任意多成员,套餐限制被整条绕开。
if err := requireOpenTeamEnabled(ctx, tenantID); err != nil {
return nil, err
}
if err := requireOpenMemberSeat(ctx, tenantID); err != nil {
return nil, err
}
count, _ := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).Where("email", req.Email).Count()
if count > 0 {
return nil, errOpen(consts.CodeUsernameExists, "邮箱已存在")
@@ -173,17 +182,44 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe
return nil, err
}
// 必须先取目标成员的角色:同文件的 OpenMemberDelete 挡了 owner,这里此前没挡。
// 请求校验 v:"in:admin,member" 只拦住了「把别人提升为 owner」,没拦住「把 owner 降为
// member」或「把 owner 置为 disabled」—— 而租户 admin 本就能建带 members:write 的应用,
// 于是 admin 可以经由开放平台把组织所有者踢出去,控制台的四处 owner 保护全被绕过。
var target *struct {
Role string `json:"role"`
}
err := dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Where("tenant_id", tenantID).
Fields("role").
Scan(&target)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if target == nil {
return nil, errOpen(consts.CodeNotFound, "用户不存在")
}
data := do.TntUsers{}
hasUpdate := false
roleChanged := false
if req.Role != nil {
if target.Role == "owner" {
return nil, errOpen(consts.CodeForbidden, "不能修改所有者的角色")
}
data.Role = *req.Role
hasUpdate = true
roleChanged = *req.Role != target.Role
}
if req.DisplayName != nil {
data.DisplayName = *req.DisplayName
hasUpdate = true
}
if req.Status != nil {
if target.Role == "owner" && *req.Status != "active" {
return nil, errOpen(consts.CodeForbidden, "不能禁用组织所有者")
}
data.Status = *req.Status
hasUpdate = true
}
@@ -192,8 +228,19 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe
return nil, nil
}
_, err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update()
return nil, err
if _, err = dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update(); err != nil {
return nil, err
}
// 与控制台 UpdateMemberRole 对齐:角色变更或停用后撤销该成员的活跃会话。
// 租户角色本就每请求从库重读,这一步是纵深防御 —— 失败只记日志,不回滚已生效的变更。
if roleChanged || (req.Status != nil && *req.Status != "active") {
if revokeErr := common.RevokeAllSessions(ctx, "tenant", req.Id); revokeErr != nil {
g.Log().Warningf(ctx, "[OpenMemberUpdate] 撤销成员会话失败 user=%d: %v", req.Id, revokeErr)
}
}
return nil, nil
}
func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteReq) (*v1.OpenMemberDeleteRes, error) {
@@ -449,6 +496,30 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v
return nil, err
}
// 权限范围此前被硬编码为 "full" —— 开放平台签发的每一把 Key 都是最大权限,
// 应用方连「只发一把 chat_only 的 Key」都做不到。改为按请求取值(默认仍是 full,
// 保持既有调用方行为不变),取值集合与 OpenProjectKeyCreate 对齐。
scope := req.Scope
if scope == "" {
scope = "full"
}
// project_id 可选,填写时必须是本租户下的活跃项目。
// relay 的项目状态闸门是 `if k.ProjectID > 0`,只有真正写入 project_id,项目归档/停用
// 才会连带吊销这把 Key;此前这里恒不写 project_id,签出的 Key 完全不受项目生命周期约束。
// 不填则是租户级集成密钥(无项目、无成员归属),仍按 key_type=project 存储 —— 控制台
// 的项目密钥列表正是按 key_type 过滤,改成 personal 反而会因缺少 user_id 而在界面上
// 彻底消失、无法吊销。
if req.ProjectID > 0 {
project, perr := s.getProjectOrError(ctx, req.ProjectID, tenantID)
if perr != nil {
return nil, perr
}
if project.Status != "active" {
return nil, errOpen(consts.CodeProjectNotActive, "项目状态不可用")
}
}
rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx)
if err != nil {
return nil, err
@@ -460,16 +531,21 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v
}
quotaLimitDecimal := billing.NewFromFloat(quotaLimit)
result, err := dao.ApiKeys.Ctx(ctx).Data(do.ApiKeys{
insertData := do.ApiKeys{
TenantId: tenantID,
Name: req.Name,
EncryptedKey: encryptedKey,
KeyPrefix: prefix,
Scope: "full",
Scope: scope,
Status: "active",
TotalQuota: &quotaLimitDecimal,
KeyType: "project",
}).Insert()
}
if req.ProjectID > 0 {
insertData.ProjectId = req.ProjectID
}
result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert()
if err != nil {
return nil, err
}
@@ -688,6 +764,41 @@ func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryRe
return &v1.OpenBillingQueryRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// requireOpenTeamEnabled 校验租户已启用团队功能。
// 与 internal/logic/tenant 的 requireTeamEnabled 同义;那个是包内私有函数,
// 此处不便跨包复用(tenant 包体量大且反向依赖会形成环),故按同一判据本地实现。
func requireOpenTeamEnabled(ctx context.Context, tenantID int64) error {
enabled, err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).Fields("team_enabled").Value()
if err != nil {
return err
}
if !enabled.Bool() {
return errOpen(consts.CodeTeamNotEnabled, consts.MsgTeamNotEnabled)
}
return nil
}
// requireOpenMemberSeat 校验租户仍有可用席位,判据与控制台 InviteMember 完全一致:
// 活跃成员数 < 生效上限(上限为 0 表示不限)。
func requireOpenMemberSeat(ctx context.Context, tenantID int64) error {
memberCount, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("status", "active").
Count()
if err != nil {
return err
}
effectiveMaxMembers, _, err := billing.GetTenantEffectiveLimits(ctx, tenantID)
if err != nil {
return gerror.Wrapf(err, "查询租户限制信息失败")
}
if effectiveMaxMembers > 0 && memberCount >= effectiveMaxMembers {
return errOpen(consts.CodeMemberLimitReached, consts.MsgMemberLimitReached)
}
return nil
}
// errOpen is a helper to create open platform errors with proper gerror codes.
func errOpen(code int, message string) error {
return gerror.NewCode(gcode.New(code, message, nil), message)
+49 -3
View File
@@ -27,6 +27,45 @@ import (
// 开放平台应用管理
// ============================================================
// openAppPermissionPoints 是开放平台应用可授予的全部权限点。
//
// 与 internal/logic/open 中各 handler 实际校验的字符串一一对应(models:read 目前尚无
// handler 使用,保留以免存量授权在编辑时被判为非法)。此前创建/更新只校验 required,
// 任意字符串都能入库:判定是精确匹配且 fail-closed,写错不会越权,但会静默失效 ——
// 写成 members:wirte 的应用在任何接口上都拿不到权限,且排查时毫无线索。
var openAppPermissionPoints = map[string]bool{
"members:read": true,
"members:write": true,
"keys:read": true,
"keys:write": true,
"projects:read": true,
"projects:write": true,
"models:read": true,
"usage:read": true,
"billing:read": true,
}
// sanitizeOpenAppPermissions 校验并规整应用权限点:去重去空、拒绝未定义的权限点。
func sanitizeOpenAppPermissions(perms []string) ([]string, error) {
seen := make(map[string]bool, len(perms))
out := make([]string, 0, len(perms))
for _, p := range perms {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
if !openAppPermissionPoints[p] {
return nil, common.NewBadRequestError("无效的权限点:" + p)
}
seen[p] = true
out = append(out, p)
}
if len(out) == 0 {
return nil, common.NewBadRequestError("请至少选择一个权限点")
}
return out, nil
}
type opnAppEncryptedSecretData struct {
EncryptedSecret any `json:"encrypted_secret"`
}
@@ -71,7 +110,6 @@ func (s *sTenant) OpenAppList(ctx context.Context, req *v1.OpenAppListReq) (*v1.
AppID: app.AppId,
Permissions: perms,
Status: app.Status,
IsSandbox: app.IsSandbox,
RateLimit: app.RateLimit,
}
if app.LastUsedAt != nil {
@@ -138,7 +176,11 @@ func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) (
return nil, err
}
permsJSON, _ := json.Marshal(req.Permissions)
perms, err := sanitizeOpenAppPermissions(req.Permissions)
if err != nil {
return nil, err
}
permsJSON, _ := json.Marshal(perms)
ipJSON, _ := json.Marshal(req.IPWhitelist)
if req.IPWhitelist == nil {
ipJSON = []byte("[]")
@@ -203,7 +245,11 @@ func (s *sTenant) OpenAppUpdate(ctx context.Context, req *v1.OpenAppUpdateReq) (
hasUpdate = true
}
if req.Permissions != nil {
permsJSON, _ := json.Marshal(req.Permissions)
perms, err := sanitizeOpenAppPermissions(req.Permissions)
if err != nil {
return nil, err
}
permsJSON, _ := json.Marshal(perms)
data.Permissions = string(permsJSON)
hasUpdate = true
}
@@ -0,0 +1,79 @@
package tenant
import (
"os"
"regexp"
"sort"
"testing"
)
// checkOpenPermissionCall 匹配 open 层 handler 里的 middleware.CheckOpenPermission(ctx, "xxx:yyy")。
var checkOpenPermissionCall = regexp.MustCompile(`CheckOpenPermission\(ctx,\s*"([^"]+)"\)`)
// TestOpenAppPermissionWhitelistCoversHandlers 保证「可授予的权限点」覆盖「实际被校验的权限点」。
//
// 两者分处两个包:白名单在 tenant(配置侧),校验在 open(执行侧)。若有人新增一个
// CheckOpenPermission("reports:read") 却忘了加进白名单,租户将永远无法把该权限授予应用 ——
// 接口对所有应用恒 403,且因为 sanitizeOpenAppPermissions 会把它判为「无效的权限点」,
// 连手工配置这条退路也没有。这类漂移不会有编译错误,只能靠测试兜住。
func TestOpenAppPermissionWhitelistCoversHandlers(t *testing.T) {
src, err := os.ReadFile("../open/open_api.go")
if err != nil {
t.Fatalf("读取 open 层源码失败: %v", err)
}
matches := checkOpenPermissionCall.FindAllStringSubmatch(string(src), -1)
if len(matches) == 0 {
t.Fatal("未在 open 层源码中匹配到任何 CheckOpenPermission 调用,正则可能已失效")
}
seen := make(map[string]bool)
var missing []string
for _, m := range matches {
perm := m[1]
if seen[perm] {
continue
}
seen[perm] = true
if !openAppPermissionPoints[perm] {
missing = append(missing, perm)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("以下权限点被 handler 校验但不在 openAppPermissionPoints 白名单中,租户无法授予:%v", missing)
}
}
func TestSanitizeOpenAppPermissions(t *testing.T) {
t.Run("去重去空并保留合法权限点", func(t *testing.T) {
got, err := sanitizeOpenAppPermissions([]string{"members:read", " ", "members:read", "usage:read"})
if err != nil {
t.Fatalf("预期通过,实际报错: %v", err)
}
want := []string{"members:read", "usage:read"}
if len(got) != len(want) {
t.Fatalf("预期 %v,实际 %v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("预期 %v,实际 %v", want, got)
}
}
})
t.Run("拒绝未定义的权限点", func(t *testing.T) {
// 拼错一个字母不会有编译错误,但会让该应用在对应接口上永久失效
if _, err := sanitizeOpenAppPermissions([]string{"members:wirte"}); err == nil {
t.Fatal("预期拒绝拼写错误的权限点,实际通过")
}
})
t.Run("拒绝空集合", func(t *testing.T) {
// 零权限的应用调用任何接口都是 403,配置时就该报错而不是留一个静默失效的应用
if _, err := sanitizeOpenAppPermissions([]string{}); err == nil {
t.Fatal("预期拒绝空权限集合,实际通过")
}
})
}
+11
View File
@@ -43,6 +43,17 @@ func Idempotency(r *ghttp.Request) {
tenantID := r.GetCtxVar(CtxKeyTenantID).Int64()
userID := r.GetCtxVar(CtxKeyUserID).Int64()
userType := r.GetCtxVar(CtxKeyUserType).String()
// 开放平台不走 TenantAuth,身份存在 OpenPlatformAuth 注入的 typed context key 里,
// 上面三个 CtxVar 全是零值。若不单独取,所有应用会共用同一个 ":0:0:" 前缀 ——
// 不同租户复用同一个 Idempotency-Key 就会读到彼此缓存的响应体(跨租户数据泄漏)。
// 主体取「应用」而非「用户」:开放平台请求由应用发起,重试也以应用为单位。
if appID := GetOpenAppID(r.Context()); appID > 0 {
userType = "open"
tenantID = GetOpenTenantID(r.Context())
userID = appID
}
idempotencyKey := buildIdempotencyKey(userType, tenantID, userID, clientKey)
// Check if this key has been processed before
+29 -2
View File
@@ -38,7 +38,6 @@ const (
ctxKeyOpenAppID openContextKey = "openAppId"
ctxKeyOpenTenantID openContextKey = "openTenantId"
ctxKeyOpenPermissions openContextKey = "openPermissions"
ctxKeyOpenIsSandbox openContextKey = "openIsSandbox"
)
var openAppCache = lcommon.NewCache("open_app", 60*time.Second)
@@ -90,6 +89,15 @@ func OpenPlatformAuth(r *ghttp.Request) {
return
}
// 租户状态校验:应用只是租户的一条独立入口,租户被停服/销户后必须一并失效。
// 控制台走 ValidateTenantPrincipal、relay 走 ValidateApiKey,两处都会拦下停服租户;
// 开放平台此前是唯一缺口 —— 停服后仍可继续建成员、建 Key、读账单,停服动作形同虚设。
// 不加缓存:这是一次主键查询,而开放平台是低频管理接口,缓存只会换来「停服延迟生效」的窗口。
if !isOpenTenantActive(r.Context(), app.TenantId) {
response.Error(r, consts.ErrTenantSuspended)
return
}
// Check IP whitelist
if err := checkOpenAppIPWhitelist(r, app); err != nil {
response.Error(r, err)
@@ -132,12 +140,31 @@ func OpenPlatformAuth(r *ghttp.Request) {
ctx = context.WithValue(ctx, ctxKeyOpenAppID, app.Id)
ctx = context.WithValue(ctx, ctxKeyOpenTenantID, app.TenantId)
ctx = context.WithValue(ctx, ctxKeyOpenPermissions, app.Permissions)
ctx = context.WithValue(ctx, ctxKeyOpenIsSandbox, app.IsSandbox)
r.SetCtx(ctx)
r.Middleware.Next()
}
// isOpenTenantActive 校验应用所属租户处于 active 状态。
// 查询失败或租户不存在时一律 fail-closed —— 鉴权环节宁可误拒,不可误放。
func isOpenTenantActive(ctx context.Context, tenantID int64) bool {
if tenantID <= 0 {
return false
}
var tenant *struct {
Status string `json:"status"`
}
err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).
Fields("status").
Scan(&tenant)
if err = lcommon.IgnoreScanNoRows(err); err != nil {
g.Log().Warningf(ctx, "[OpenAuth] tenant status lookup failed tenant=%d: %v", tenantID, err)
return false
}
return tenant != nil && tenant.Status == "active"
}
// checkOpenAppNonce enforces replay protection by recording each nonce in Redis.
// 用 SET NX 原子写入 nonce:首次出现返回 OK 放行;已存在说明是重放,写 401 并返回 false。
// Redis 故障时 fail-open(与限流一致),仅记告警,因为时间戳窗口本身已把可重放时间
@@ -14,7 +14,6 @@ interface OpenApp {
app_id: string
permissions: string[]
status: string
is_sandbox: boolean
rate_limit: number
last_used_at: string
created_at: string
@@ -209,7 +208,6 @@ function copyToClipboard(text: string) {
<div class="flex-1">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-lg font-semibold text-gray-900">{{ app.name }}</h3>
<span v-if="app.is_sandbox" class="badge badge-warning">沙箱</span>
<span :class="app.status === 'active' ? 'badge-success' : 'badge-danger'" class="badge">
{{ app.status === 'active' ? '已启用' : '已禁用' }}
</span>