From fa4357a3f276bdf5f63ab4d97e0e461b1db42c3a Mon Sep 17 00:00:00 2001 From: loveuer Date: Wed, 9 Sep 2026 21:00:41 -0700 Subject: [PATCH] fix: prevent user permission escalation --- README.md | 2 ++ app.go | 42 +++++++++++++++++++++++++++++++++++++ app_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++++ frontend/src/App.jsx | 18 ++++++++-------- 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4573d33..d4d9ed9 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ 首次使用空数据库启动时,终端会输出 `admin` 的随机密码。该账号第一次登录后必须立即修改密码。 +拥有用户管理权限的账号只能授予自己已有的权限,也不能编辑、重置或删除权限高于自己的账号。 + ## 分享链接 - 下载:`/uf/` diff --git a/app.go b/app.go index 8f0dfcc..7126061 100644 --- a/app.go +++ b/app.go @@ -138,6 +138,36 @@ func (a *App) manageUsers(c *gin.Context) { c.Next() } +func permissionsWithin(actor *User, canManageUsers, canUpload, canAdvancedUpload bool) bool { + if canManageUsers && !actor.CanManageUsers { + return false + } + if canAdvancedUpload && !actor.CanAdvancedUpload { + return false + } + return !canUpload || actor.CanUpload || actor.CanAdvancedUpload +} + +func canManageTarget(actor, target *User) bool { + return permissionsWithin(actor, target.CanManageUsers, target.CanUpload, target.CanAdvancedUpload) +} + +func requireAssignablePermissions(c *gin.Context, canManageUsers, canUpload, canAdvancedUpload bool) bool { + if permissionsWithin(currentUser(c), canManageUsers, canUpload, canAdvancedUpload) { + return true + } + c.JSON(http.StatusForbidden, gin.H{"error": "不能授予自己不具备的权限"}) + return false +} + +func requireManageableTarget(c *gin.Context, target *User) bool { + if canManageTarget(currentUser(c), target) { + return true + } + c.JSON(http.StatusForbidden, gin.H{"error": "不能管理权限高于自己的用户"}) + return false +} + type loginRequest struct { Username string `json:"username"` Password string `json:"password"` @@ -562,6 +592,9 @@ func (a *App) createUser(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"}) return } + if !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) { + return + } password, err := randomToken(12) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"}) @@ -600,6 +633,9 @@ func (a *App) updateUser(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return } + if !requireManageableTarget(c, &user) || !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) { + return + } err := a.db.Model(&user).Updates(map[string]any{"username": req.Username, "can_manage_users": req.CanManageUsers, "can_upload": req.CanUpload, "can_advanced_upload": req.CanAdvancedUpload}).Error if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"}) @@ -619,6 +655,9 @@ func (a *App) resetPassword(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return } + if !requireManageableTarget(c, &user) { + return + } password, err := randomToken(12) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"}) @@ -653,6 +692,9 @@ func (a *App) deleteUser(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return } + if !requireManageableTarget(c, &user) { + return + } var files []SharedFile if err := a.db.Where("owner_id = ?", id).Find(&files).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"}) diff --git a/app_test.go b/app_test.go index 9295860..b06c403 100644 --- a/app_test.go +++ b/app_test.go @@ -305,6 +305,55 @@ func TestDeleteUserRemovesOwnedFilesAndProtectsIDOne(t *testing.T) { } } +func TestUserManagerCannotGrantOrTakeOverPermissionsTheyDoNotHave(t *testing.T) { + app, _ := testApp(t) + passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost) + manager := User{Username: "manager", PasswordHash: string(passwordHash), CanManageUsers: true, CanUpload: true} + higherUser := User{Username: "higher", PasswordHash: string(passwordHash), CanAdvancedUpload: true} + basicUser := User{Username: "editable", PasswordHash: string(passwordHash), CanUpload: true} + for _, user := range []*User{&manager, &higherUser, &basicUser} { + if err := app.db.Create(user).Error; err != nil { + t.Fatal(err) + } + } + router := app.router() + cookie := loginAs(t, router, manager.Username, "password-123") + + createBody, _ := json.Marshal(map[string]any{"username": "escalated", "canAdvancedUpload": true}) + created := request(t, router, http.MethodPost, "/api/users", bytes.NewReader(createBody), "application/json", cookie) + if created.Code != http.StatusForbidden { + t.Fatalf("create advanced user status = %d, body = %s", created.Code, created.Body.String()) + } + + updateBody, _ := json.Marshal(map[string]any{"username": basicUser.Username, "canUpload": true, "canAdvancedUpload": true}) + updated := request(t, router, http.MethodPut, "/api/users/"+jsonNumber(basicUser.ID), bytes.NewReader(updateBody), "application/json", cookie) + if updated.Code != http.StatusForbidden { + t.Fatalf("grant advanced permission status = %d, body = %s", updated.Code, updated.Body.String()) + } + + reset := request(t, router, http.MethodPost, "/api/users/"+jsonNumber(higherUser.ID)+"/reset-password", nil, "", cookie) + if reset.Code != http.StatusForbidden { + t.Fatalf("reset higher user status = %d, body = %s", reset.Code, reset.Body.String()) + } + + downgradeBody, _ := json.Marshal(map[string]any{"username": higherUser.Username, "canUpload": true}) + downgraded := request(t, router, http.MethodPut, "/api/users/"+jsonNumber(higherUser.ID), bytes.NewReader(downgradeBody), "application/json", cookie) + if downgraded.Code != http.StatusForbidden { + t.Fatalf("edit higher user status = %d, body = %s", downgraded.Code, downgraded.Body.String()) + } + + deleted := request(t, router, http.MethodDelete, "/api/users/"+jsonNumber(higherUser.ID), nil, "", cookie) + if deleted.Code != http.StatusForbidden { + t.Fatalf("delete higher user status = %d, body = %s", deleted.Code, deleted.Body.String()) + } + + createBody, _ = json.Marshal(map[string]any{"username": "allowed", "canUpload": true}) + created = request(t, router, http.MethodPost, "/api/users", bytes.NewReader(createBody), "application/json", cookie) + if created.Code != http.StatusCreated { + t.Fatalf("create user with owned permission status = %d, body = %s", created.Code, created.Body.String()) + } +} + func jsonNumber(id uint) string { data, _ := json.Marshal(id) return string(data) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c13df09..81ba167 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -150,21 +150,22 @@ function UploadResult({ file, onAgain }) { } -const emptyUser = { username: '', canManageUsers: false, canUpload: true, canAdvancedUpload: false } - function UserModal({ value, onClose, onSaved }) { - const [form, setForm] = useState(value || emptyUser) + const actor = useAppStore((s) => s.user) + const canGrantUpload = actor.canUpload || actor.canAdvancedUpload + const [form, setForm] = useState(value || { username: '', canManageUsers: false, canUpload: canGrantUpload, canAdvancedUpload: false }) const [error, setError] = useState('') const submit = async (e) => { e.preventDefault(); setError('') try { onSaved(await api(value ? `/api/users/${value.id}` : '/api/users', json(value ? 'PUT' : 'POST', form))) } catch (err) { setError(err.message) } } - const checkbox = (key, title, hint) => - return

{value ? '编辑用户' : '创建用户'}

setForm({ ...form, username: e.target.value })} />
{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件')}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载')}
{error &&

{error}

}
+ const checkbox = (key, title, hint, disabled = false) => + return

{value ? '编辑用户' : '创建用户'}

setForm({ ...form, username: e.target.value })} />

只能分配你当前拥有的权限。

{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.canAdvancedUpload)}
{error &&

{error}

}
} function UsersPanel({ notify }) { + const actor = useAppStore((s) => s.user) const users = useAppStore((s) => s.users) const loadUsers = useAppStore((s) => s.loadUsers) const [editing, setEditing] = useState(undefined) @@ -172,9 +173,10 @@ function UsersPanel({ notify }) { const [secret, setSecret] = useState(null) useEffect(() => { loadUsers() }, [loadUsers]) const saved = async (result) => { setModalOpen(false); setEditing(undefined); await loadUsers(); if (result.temporaryPassword) setSecret({ title: '用户已创建', password: result.temporaryPassword }); else notify('用户信息已保存') } - const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) } - const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') } - return

用户管理

权限修改立即生效

{users.map((user) =>
{user.username.slice(0, 1).toUpperCase()}

{user.username}

#{user.id}{user.mustChangePassword && 待改密}

{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}

{user.id !== 1 && }
)}
+ const manageable = (user) => (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload) + const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; try { const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) } catch (error) { notify(error.message) } } + const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; try { await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') } catch (error) { notify(error.message) } } + return

用户管理

只能管理权限不高于自己的用户

{users.map((user) =>
{user.username.slice(0, 1).toUpperCase()}

{user.username}

#{user.id}{user.mustChangePassword && 待改密}

{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}

{manageable(user) ?
{user.id !== 1 && }
: 权限高于你}
)}
{modalOpen && setModalOpen(false)} onSaved={saved} />} {secret &&

{secret.title}

该密码只显示这一次,用户登录后必须修改。

{secret.password}
}